Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/cpp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: C++

# Compiles the generated moq-ffi C++ bindings with clang, outside Nix. `just
# check` already runs `just cpp check` with the dev shell's gcc; the dev shell
# has no clang++, and adding one would change which compiler every other recipe
# gets as `cc`. MSVC runs in nightly.yml, for the reason the Windows Rust check
# does: those runners are throttled too hard to gate every pull request.
#
# Outside Nix the generator comes from `cargo install` at the tag flake.nix
# pins; see the comment there for every other place that names it.

permissions:
contents: read

on:
pull_request:
# `closed` is here only so merging/closing a PR cancels its in-flight run
# via the concurrency group below; the job itself is skipped on close.
types: [opened, synchronize, reopened, closed]
paths:
- "cpp/ffi/**"
- "cpp/justfile"
- "rs/moq-ffi/**"
- "Cargo.lock"
- ".github/workflows/cpp.yml"

concurrency:
group: cpp-${{ github.ref }}
cancel-in-progress: true

jobs:
clang:
name: C++ (clang)
if: github.event.action != 'closed'
runs-on: ubuntu-24.04
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

# Pinned: `--locked` fixes just's own dependencies, not which version of
# just cargo selects.
- name: Install just
run: cargo install --locked just@1.52.0

- name: Install uniffi-bindgen-cpp
run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2

- name: Check
run: just cpp check
env:
CXX: clang++
37 changes: 37 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,43 @@ jobs:
shell: bash
run: just rs windows

# The generated moq-ffi C++ bindings under MSVC, with exceptions and RTTI
# off. cpp.yml covers clang on pull requests and `just check` covers gcc; this
# is the Windows half, nightly for the same runner cost as `windows`.
cpp-windows:
name: C++ (MSVC)
runs-on: windows-latest
timeout-minutes: 60

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

# aws-lc-rs assembles its x86_64 crypto with NASM on Windows.
- name: Install NASM
shell: pwsh
run: |
choco install nasm -y --no-progress
"C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append

- name: Install just
shell: bash
run: cargo install --locked just@1.52.0

# The tag flake.nix pins; see the comment there.
- name: Install uniffi-bindgen-cpp
shell: bash
run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2

- name: Check
shell: bash
run: just cpp check

# `just rs macos` is the only thing that compiles moq-video's VideoToolbox
# encode/decode and its ScreenCaptureKit / AVFoundation capture, plus
# moq-audio's ScreenCaptureKit system audio and its TCC permission pre-check.
Expand Down
3 changes: 3 additions & 0 deletions cpp/ffi/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Regenerated from rs/moq-ffi by `just cpp check`.
/generated/
/build/
34 changes: 34 additions & 0 deletions cpp/ffi/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Compiles the generated moq-ffi bindings and the probe that exercises them.
# `just cpp check` generates the bindings and drives this; it is not a package.
cmake_minimum_required(VERSION 3.16)
project(moq-ffi-probe LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# The cargo output directory holding the moq-ffi cdylib.
set(MOQ_FFI_LIB_DIR "" CACHE PATH "Directory containing the moq-ffi shared library")
if(NOT MOQ_FFI_LIB_DIR)
message(FATAL_ERROR "Set MOQ_FFI_LIB_DIR to the cargo output directory")
endif()

set(GENERATED ${CMAKE_CURRENT_SOURCE_DIR}/generated)
find_package(Threads REQUIRED)

add_executable(probe probe.cpp ${GENERATED}/moq.cpp)
target_include_directories(probe PRIVATE ${GENERATED})

if(MSVC)
# No exceptions and no RTTI, the way Unreal builds.
target_compile_options(probe PRIVATE /permissive- /EHs-c- /GR-)
target_compile_definitions(probe PRIVATE _HAS_EXCEPTIONS=0)
target_link_libraries(probe PRIVATE ${MOQ_FFI_LIB_DIR}/moq_ffi.dll.lib)
add_custom_command(TARGET probe POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MOQ_FFI_LIB_DIR}/moq_ffi.dll $<TARGET_FILE_DIR:probe>)
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()
32 changes: 32 additions & 0 deletions cpp/ffi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# C++ bindings for moq-ffi

C++17 bindings for [rs/moq-ffi](../../rs/moq-ffi), generated by `uniffi-bindgen-cpp`. This directory holds the generator config and a probe program; the generated sources are not checked in. The ergonomic `moq::` layer and the package that ships them come later.

## Generating

`just cpp check` builds moq-ffi for the host, regenerates `generated/` (gitignored), then compiles and runs `probe.cpp` against it. The probe connects over QUIC, subscribes to a track, reads a frame through a future, cancels a pending read, and checks errors come back as values. It builds with exceptions and RTTI disabled.

The generator is a fork, [kixelated/uniffi-bindgen-cpp](https://github.com/kixelated/uniffi-bindgen-cpp). It carries LiveKit's async support ported to uniffi 0.32, plus the `error_style = "expected"` option this directory's `uniffi.toml` turns on. The dev shell provides it. Without Nix:

```bash
cargo install uniffi-bindgen-cpp --locked \
--git https://github.com/kixelated/uniffi-bindgen-cpp \
--tag v0.11.0-kixelated.1+v0.32.2
```

`flake.nix` pins the same tag and lists every other place that names it.

## Shape

Every generated type lives in `namespace moq`, spelled as in Rust (`moq::MoqClient`). Objects are `std::shared_ptr`; records and enums are values.

- A fallible call returns `uniffi::expected<T, moq::MoqError>`. `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<T, moq::MoqError>`. 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.
125 changes: 125 additions & 0 deletions cpp/ffi/probe.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Exercises the generated C++ bindings end to end over a real QUIC session: connect,
// subscribe, read a frame through a future, cancel a pending read, and observe errors
// as returned values. Built with exceptions and RTTI disabled.

#include <moq.hpp>

#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <optional>
#include <string>
#include <variant>
#include <vector>

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 <typename T>
T ok(uniffi::expected<T, moq::MoqError> result, const char *what) {
if (!result) {
fail(what, result.error());
}
return std::move(*result);
}

void ok(uniffi::expected<void, moq::MoqError> result, const char *what) {
if (!result) {
fail(what, result.error());
}
}

std::vector<uint8_t> bytes(const std::string &text) {
return std::vector<uint8_t>(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<moq::MoqError::kBind>(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<moq::MoqError::kCancelled>(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;
}
4 changes: 4 additions & 0 deletions cpp/ffi/uniffi.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# uniffi-bindgen-cpp config for rs/moq-ffi. Errors are returned, never thrown, so the
# bindings build with exceptions disabled (Unreal's default).
[bindings.cpp]
error_style = "expected"
51 changes: 51 additions & 0 deletions cpp/justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env just --justfile
#
# The generated C++ bindings for rs/moq-ffi. Invoked from the repo root as
# `just cpp <recipe>` via the `mod cpp` import. The OBS plugin has its own
# module, `just obs`.

set working-directory := '.'

default:
just check

# Takes an optional newline-separated list of changed files and skips when none
# are in scope; `just cpp check` (no FILES) always runs. CXX picks the compiler
# on Unix; Windows uses MSVC.

# Build moq-ffi, regenerate cpp/ffi/generated, then compile and run the probe.
check $FILES="":
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "$FILES" ]] && ! grep -qE '^(cpp/ffi/|cpp/justfile$|rs/moq-ffi/)' <<< "$FILES"; then
echo "cpp: no C++ binding changes; skipping."
exit 0
fi
if ! command -v uniffi-bindgen-cpp >/dev/null 2>&1; then
echo "cpp check: uniffi-bindgen-cpp not on PATH, skipping" >&2
echo " install: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.1+v0.32.2" >&2
exit 0
fi

cargo build --locked --package moq-ffi
target_dir=$(cargo metadata --format-version 1 --no-deps | jq -r .target_directory)/debug
case "$(uname -s)" in
Darwin) cdylib="$target_dir/libmoq_ffi.dylib" ;;
MINGW* | MSYS* | CYGWIN*) cdylib="$target_dir/moq_ffi.dll" ;;
*) cdylib="$target_dir/libmoq_ffi.so" ;;
esac

rm -rf ffi/generated
uniffi-bindgen-cpp --library "$cdylib" --config ffi/uniffi.toml --out-dir ffi/generated

cmake -S ffi -B ffi/build -DMOQ_FFI_LIB_DIR="$target_dir" -DCMAKE_BUILD_TYPE=Debug
cmake --build ffi/build --config Debug
if [[ -x ffi/build/probe ]]; then
ffi/build/probe
else
ffi/build/Debug/probe.exe
fi

# Remove the generated bindings and the probe build.
clean:
rm -rf ffi/generated ffi/build
Loading
Loading