#!/usr/bin/env bash # Cross-language media interop test against THIS checkout. # # Unlike test/smoke (which installs each client from its # 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/, go/, cpp/moq, js/, and rs/moq-c, 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 with an Opus tone per audio channel (the Rust CLI an AAC one too), and # confirms every subscriber sees video and decodes that tone in every rendition, # contiguous and in phase with its timestamps (see phase_checked), before the # timeout (tone.py judges the decode; the browser judges its own playback). A # publisher with its channels' tones swapped must then fail every subscriber's # check. The browser subscriber # also verifies rendered WebCodecs output and player pause/resume, then that a # session the relay refuses hands Chromium the close code and reason. # # Every client dials with a token minted for its cell and verified by # `moq auth serve`. Clients that print the grant the relay sent back over AUTH # must report exactly what their token implies, and a final round per enforcing # publisher mints a token that excludes its broadcast: the publisher must fail # loud with Unauthorized and no subscriber may see data. AUTH is only on the # work-in-progress moq-lite-07, so those clients dial it alone while the rest keep # their defaults, and the matrix also crosses versions through the relay. # # `--versions` reruns the matrix and the tails with every client pinned to one # version per pass, such as each even moq-transport draft (`--versions ietf`). set -euo pipefail if [[ "${GITHUB_ACTIONS:-}" != true && "${LOCAL_SUPERVISED:-}" != 1 ]]; then exec bash "$(dirname "$0")/../../sh/local.sh" bash "$0" "$@" fi INTEROP_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) WORKSPACE=$(cd "$INTEROP_DIR/../.." && pwd) CLIENTS="$INTEROP_DIR/clients" # The native-JS subscriber. A released-compat run swaps in its own client, which runs # against either the checkout's or the released packages (see compat/client.ts). NATIVE_DIR="${INTEROP_COMPAT_CLIENT:-$CLIENTS/js-native}" NATIVE_SCRIPT=subscribe.ts [[ -z "${INTEROP_COMPAT_CLIENT:-}" ]] || NATIVE_SCRIPT=client.ts # Run directory, reserved ports, and process-group ownership. See test/README.md. # shellcheck source-path=SCRIPTDIR source=../lib/harness.sh source "$INTEROP_DIR/../lib/harness.sh" # Captured before the parse below consumes it, so the rerun command carries every # flag and every environment override this run was actually given. RERUN="$(harness_env INTEROP_TIMEOUT INTEROP_FPS INTEROP_SIZE INTEROP_PORT INTEROP_PROFILE RELAY_BIN CLI_BIN INTEROP_SUB_MOQ INTEROP_VERSION INTEROP_COMPAT_CLIENT INTEROP_DECODE INTEROP_PUB_LIVE INTEROP_FETCH_DATA INTEROP_FETCH_TRACK)just test interop$(harness_argv "$@")" PUBLISHERS="rust" SUBSCRIBERS="rust" # The idle-out check and the finite-tail lanes guard this checkout's relay and clients. A # run that swaps in other binaries or JS clients (the wire-compat lanes run released ones) # can't be held to them: a released client may not close cleanly, a released relay names no # connection when one closes, and the tail clients are built from this checkout only. IN_TREE=1 if [[ -n "${RELAY_BIN:-}${CLI_BIN:-}${INTEROP_SUB_MOQ:-}${INTEROP_COMPAT_CLIENT:-}" ]]; then IN_TREE=0 fi TIMEOUT="${INTEROP_TIMEOUT:-20}" FPS="${INTEROP_FPS:-30}" SIZE="${INTEROP_SIZE:-320x240}" # Empty means "any reserved port"; INTEROP_PORT pins one instead. PORT="${INTEROP_PORT:-}" URL="" KEY="" # the HMAC key every cell's token is signed with (set once the relay's auth server starts) METRICS_URL="" # the relay's internal /metrics, served only by an in-tree run's relay NEGATIVE=0 MEDIA=0 MEDIA_CASES="" MEDIA_ENGINES="chromium" MEDIA_PUBLISHER="chromium" TAIL_ONLY=0 SOCKET_ONLY=0 FINISH_ONLY=0 # The versions the matrix runs on, one pass each. `auto` leaves every client its defaults, # except the clients the harness reads a grant from, which offer $AUTH_VERSION alone. Any other # entry pins every client in its pass to that version, and `ietf` expands to IETF_VERSIONS. VERSIONS="auto" IETF_VERSIONS="moq-transport-18,moq-transport-20,moq-transport-22" # Cargo profile for the relay/cli/moq-c builds. Debug compiles faster, which is # what an interop test wants; the workload (320x240@30) is trivial either way. PROFILE="${INTEROP_PROFILE:-debug}" # Binaries under test. Built from source below unless overridden to point at a # prebuilt (mirrors test/smoke's RELAY_BIN/CLI_BIN escape hatch). RELAY="${RELAY_BIN:-}" MOQ="${CLI_BIN:-}" require_value() { # require_value "$@": the flag plus the rest of the argv. Ensures a # non-flag value follows, so `set -u` doesn't abort on a bare `--timeout`. if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then echo "error: $1 requires a value" >&2 exit 2 fi } while [[ $# -gt 0 ]]; do case "$1" in --publishers) require_value "$@" PUBLISHERS="$2" shift 2 ;; --subscribers) require_value "$@" SUBSCRIBERS="$2" shift 2 ;; --timeout) require_value "$@" TIMEOUT="$2" shift 2 ;; --negative) NEGATIVE=1 shift ;; --finish) FINISH_ONLY=1 shift ;; --tail) TAIL_ONLY=1 shift ;; --sockets) SOCKET_ONLY=1 TAIL_ONLY=1 shift ;; --versions) require_value "$@" VERSIONS="$2" shift 2 ;; --media) MEDIA=1 shift ;; --media-cases) require_value "$@" MEDIA_CASES="$2" shift 2 ;; --media-engines) require_value "$@" MEDIA_ENGINES="$2" MEDIA=1 shift 2 ;; --media-publisher) require_value "$@" MEDIA_PUBLISHER="$2" MEDIA=1 shift 2 ;; # The full matrix. --timeout 30 gives headless Chromium cold-start # headroom; flags after it still override. --all) PUBLISHERS="rust,python,go,cpp,js" SUBSCRIBERS="rust,python,go,cpp,js,js-native-node,js-native-bun,c,gst" TIMEOUT=30 shift ;; *) echo "unknown arg: $1" >&2 exit 2 ;; esac done if [[ -n "$MEDIA_CASES" && "$MEDIA" -ne 1 ]]; then echo "error: --media-cases requires --media" >&2 exit 2 fi if [[ "$SOCKET_ONLY" -eq 1 && "$VERSIONS" != auto ]]; then echo "error: --sockets pins moq-lite-07-wip; omit --versions" >&2 exit 2 fi # Numeric guards so a fat-fingered --timeout / INTEROP_PORT fails clearly here # instead of surfacing later as a cryptic `timeout` or relay-bind error. [[ "$TIMEOUT" =~ ^[0-9]+(\.[0-9]+)?$ ]] || { echo "error: timeout must be a positive number (got '$TIMEOUT')" >&2 exit 2 } [[ -z "$PORT" || "$PORT" =~ ^[0-9]+$ ]] || { echo "error: port must be numeric (got '$PORT')" >&2 exit 2 } if [[ "$FINISH_ONLY" -eq 1 ]]; then if [[ "$NEGATIVE" -eq 1 || "$MEDIA" -eq 1 || "$TAIL_ONLY" -eq 1 || "$IN_TREE" -eq 0 ]]; then echo "error: --finish requires in-tree clients and is separate from --tail, --media, and --negative" >&2 exit 2 fi fi if [[ "$TAIL_ONLY" -eq 1 ]]; then if [[ "$NEGATIVE" -eq 1 || "$MEDIA" -eq 1 ]]; then echo "error: --tail, --media, and --negative are separate runs" >&2 exit 2 fi if [[ "$IN_TREE" -eq 0 ]]; then echo "error: --tail builds its clients from this checkout; drop the binary and client overrides" >&2 exit 2 fi PUBLISHERS="rust,js-native-node,js-native-bun" SUBSCRIBERS="$PUBLISHERS" fi # The media checks drive both roles from the browser client and never touch the matrix, so they # pick their own axes rather than accepting --publishers / --subscribers. if [[ "$MEDIA" -eq 1 ]]; then if [[ "$NEGATIVE" -eq 1 ]]; then echo "error: --media and --negative are separate runs" >&2 exit 2 fi PUBLISHERS="js" SUBSCRIBERS="js" fi if [[ ! "$MEDIA_ENGINES" =~ ^(chromium|firefox|webkit)(,(chromium|firefox|webkit))*$ ]]; then echo "error: media engines must be a comma-separated list of chromium,firefox,webkit" >&2 exit 2 fi IFS=',' read -r -a MEDIA_ENGINE_LIST <<<"$MEDIA_ENGINES" # WebKit's publishing stays with the Safari work; Playwright's WebKit is not Safari. if [[ ! "$MEDIA_PUBLISHER" =~ ^(chromium|firefox)$ ]]; then echo "error: the media publisher must be chromium or firefox" >&2 exit 2 fi IFS=',' read -r -a PUB_LIST <<<"$PUBLISHERS" IFS=',' read -r -a SUB_LIST <<<"$SUBSCRIBERS" if [[ "$VERSIONS" != auto ]] && [[ "$MEDIA" -eq 1 || "$NEGATIVE" -eq 1 || -n "${INTEROP_VERSION:-}" ]]; then echo "error: --versions applies to the matrix and the tails, not --media, --negative, or wire-compat" >&2 exit 2 fi # A version the relay does not offer could only fail its pass, so refuse it here. VERSION_LIST=() IFS=',' read -r -a requested <<<"$VERSIONS" for version in "${requested[@]}"; do if [[ "$version" == ietf ]]; then IFS=',' read -r -a expanded <<<"$IETF_VERSIONS" VERSION_LIST+=("${expanded[@]}") elif [[ "$version" == auto ]] || grep -qx " \"$version\"," "$INTEROP_DIR/interop.toml"; then VERSION_LIST+=("$version") else echo "error: unknown version '$version': use auto, ietf, or a version interop.toml offers" >&2 exit 2 fi done # The pass running now. VERSION=auto needs() { # needs : true if appears in either list. local lang="$1" x for x in "${PUB_LIST[@]}" "${SUB_LIST[@]}"; do [[ "$x" == "$lang" ]] && return 0; done return 1 } # The browsers whose tabs prove their route key to the relay (route-key.ts). ROUTE_KEY_ENGINES=(chromium firefox) # True if any browser/native JS client is in play (they share one bun install). needs_js() { needs js || needs js-native || needs js-native-node || needs js-native-bun } # js/net speaks the lite-07 wire from before the route layer until # quest/m2/cluster-routing/routes/js.md ports it and removes this: native JS dials its # defaults in the matrix, and a lane running it on moq-lite-07-wip is skipped. JS_LITE07_SKIP="JS speaks pre-route lite-07 until quest/m2/cluster-routing/routes/js.md" # js_lite07 : true if the lane runs native JS on moq-lite-07-wip. js_lite07() { local version="$1" client [[ "$version" == moq-lite-07-wip ]] || return 1 for client in "${@:2}"; do [[ "$client" != js-native-* ]] || return 0; done return 1 } for version in "${VERSION_LIST[@]}"; do if [[ "$version" == moq-lite-07-wip ]] && needs_js; then echo "error: $JS_LITE07_SKIP; pin moq-lite-07-wip without JS clients" >&2 exit 2 fi done # Released-wire compatibility (compat.sh) pins one version and may run released # binaries that predate AUTH, so it runs anonymous: it compares the wire, and the # default matrix covers auth. COMPAT=0 [[ -n "${INTEROP_VERSION:-}" ]] && COMPAT=1 # The clients that print the grant they received as an `auth granted` line. The # Python, Go, and C++ clients read it through moq-ffi; moq-c's frozen ABI and # GStreamer expose none, and the browser's shared connection keeps its session private. # Native JS prints one too, but dials its defaults until it speaks lite-07's route # layer (see JS_LITE07_SKIP). Grants are checked on $AUTH_VERSION, so only in the `auto` pass. prints_grant() { [[ "$COMPAT" -eq 0 && "$VERSION" == auto ]] || return 1 case "$1" in rust | python | go | cpp) return 0 ;; *) return 1 ;; esac } # The publishers whose refusal the harness can read: each logs moq-net's error. # The browser driver reports no session error. enforces_grant() { [[ "$COMPAT" -eq 0 && "$VERSION" == auto ]] || return 1 case "$1" in rust | python | go | cpp) return 0 ;; *) return 1 ;; esac } # The version every client offers alone in this pass, or nothing in the `auto` pass. pinned_version() { [[ "$VERSION" == auto ]] || printf '%s' "$VERSION" } # The one version offers in this pass, or nothing for its defaults. # shellcheck disable=SC2329 # reached from functions 'harness_spawn' invokes cell_version() { if [[ "$VERSION" != auto ]]; then printf '%s' "$VERSION" elif [[ "$1" == rust ]] || prints_grant "$1"; then printf '%s' "$AUTH_VERSION" fi } # Set FLAGS to 's version flag in this pass, plus `--grant` for the Python, Go, and C++ # clients when their grant is checked. FLAGS=() # shellcheck disable=SC2329 # reached from functions 'harness_spawn' invokes client_flags() { local version version=$(cell_version "$1") FLAGS=() [[ -z "$version" ]] || FLAGS+=(--version "$version") case "$1" in python | go | cpp) ! prints_grant "$1" || FLAGS+=(--grant) ;; esac } harness_begin interop "$RERUN" # Where the matrix and tail logs go: the run directory, or one of its own per pinned pass. LOGS="$HARNESS_RUN" # ── results ───────────────────────────────────────────────────────────────── # Each mode is its own suite in the CI report. SUITE=interop [[ "$NEGATIVE" -eq 0 ]] || SUITE=interop-negative [[ "$MEDIA" -eq 0 ]] || SUITE=media # The lite drafts the finite-track drop lanes cross between Rust and Node. DROP_VERSIONS=(moq-lite-05 moq-lite-06 moq-lite-07-wip) # The lite drafts the compressed-track lanes cross: lite-07 carries the flag, lite-06 gets inflated bytes. COMPRESS_VERSIONS=(moq-lite-07-wip moq-lite-06) # The compressed catalog's concurrent readers: lite-07 carries the flag, and lite-06 and a # moq-transport reader that does not offer FLATE share the relay's one inflated copy. The # lite-07 reader returns with quest/m2/cluster-routing/routes/js.md (see JS_LITE07_SKIP). CATALOG_VERSIONS=(moq-lite-06 moq-transport-22) # Every publisher in this checkout sends a tone per audio channel, and every subscriber decodes # AUDIO_MS of it for tone.py (the browser: its page) to check against the publisher's list. This # is the one place the tones are chosen. A stereo publisher sends a different tone per channel, so a # channel swap fails too. A run that swaps in released CLIs or JS clients (the wire-compat lanes) # checks audio only in its decoding media lanes, and the bindings' clients, which always check it, # refuse to run in the rest. AUDIO=1 [[ -z "${CLI_BIN:-}${INTEROP_SUB_MOQ:-}${INTEROP_COMPAT_CLIENT:-}" || "${INTEROP_DECODE:-0}" == 1 ]] || AUDIO=0 if [[ "$AUDIO" -eq 0 ]]; then for sub in "${SUB_LIST[@]}"; do case "$sub" in rust) ;; js-native-node | js-native-bun) [[ -n "${INTEROP_COMPAT_CLIENT:-}" ]] || sub_needs_audio="$sub" ;; *) sub_needs_audio="$sub" ;; esac if [[ -n "${sub_needs_audio:-}" ]]; then echo "error: the $sub subscriber always checks audio, which a run with CLI_BIN, INTEROP_SUB_MOQ, or INTEROP_COMPAT_CLIENT turns off without INTEROP_DECODE" >&2 exit 2 fi done fi AUDIO_MS=600 STEREO_TONE="440,660" publisher_tone() { case "$1" in python | cpp) echo 440 ;; *) echo "$STEREO_TONE" ;; esac } # The wrong-tone control's publisher sends the stereo tone with its channels swapped. SWAPPED_TONE="660,440" # The audio renditions sends the tone in, by codec: the catalog codec up to its first `.`. # Every subscriber decodes each one, the native ones to ..{txt,pcm}. The wire-compat # lanes send Opus alone: the released @moq/hang's libav fallback has no AAC decoder. publisher_codecs() { case "$1" in rust) if [[ -n "${INTEROP_COMPAT_CLIENT:-}" ]]; then echo opus; else echo "opus,mp4a"; fi ;; *) echo opus ;; esac } # phase_checked : whether tone.py also holds the tone's phase to the # decode's timestamps, which fails a misapplied pre-skip. Every publisher's tone starts at # timestamp zero except the browser's, which stamps its file from its own page clock, and a CLI_BIN # whose fMP4 import restamps on arrival (INTEROP_PUB_LIVE=1: a released CLI's live import). The native-JS # subscriber hands WebCodecs raw Opus without the OpusHead, as the player does, which keeps the # pre-skip for the player to trim, and its libav fallback stamps milliseconds. GStreamer's AAC is # skipped for its decoder: `faad`, which decodebin picks, drops the first frame's output and stamps # the rest a frame early, so it reads the priming 1024 samples early whatever the timestamps say. # shellcheck disable=SC2329 # reached from run_cell, which 'harness_spawn' invokes phase_checked() { [[ "$1" != js* && ("$1" != rust || "${INTEROP_PUB_LIVE:-0}" != 1) && "$2" != js-native-* && ("$2" != gst || "$3" == opus) ]] } # Record a case for the CI report: `record case=C [field=value...]` (see # harness_result). Every session in a case dials the same relay with each client's own # transport choice, and the version of its pass, unless compat.sh pins one for the whole run. record() { harness_result "$1" suite="$SUITE" transport=auto version="${INTEROP_VERSION:-$VERSION}" "${@:2}" } # Call for each media run on . The planning # pass and the real one share it, so they cannot disagree on which runs exist. media_runs() { local action="$1" engine="$2" who="$2" local roles=(--publisher-engine "$MEDIA_PUBLISHER" --player-engine "$engine") # Chromium publishing keeps the names earlier reports used. [[ "$MEDIA_PUBLISHER" == chromium ]] || who="$MEDIA_PUBLISHER to $engine" if [[ -n "$MEDIA_CASES" ]]; then "$action" "$who: media cases: $MEDIA_CASES" "${roles[@]}" --cases "$MEDIA_CASES" --focused return fi "$action" "$who: media output + lifecycle" "${roles[@]}" "$action" "$who: control: frozen video" "${roles[@]}" --fault frozen-video --cases none --expect-fail "video progress" "$action" "$who: control: silent audio" "${roles[@]}" --fault silent-audio --cases none --expect-fail "audio tone" "$action" "$who: control: offset audio" "${roles[@]}" --fault audio-offset --cases none --expect-fail "audio/video sync" "$action" "$who: control: leaked session" "${roles[@]}" --leak --cases detach --expect-fail "resource baseline" "$action" "$who: control: lagging latecomer" "${roles[@]}" --lag --cases late-join --expect-fail "late join reaches live" # Skipped where the publisher has no AAC encoder, which for Chromium is every Linux build. "$action" "$who: AAC publish and codec change" "${roles[@]}" --cases aac,audio-codec --focused # The native runs have no browser publisher, so only the default Chromium-publisher pass runs them. [[ "$MEDIA_PUBLISHER" == chromium ]] || return 0 # The Rust CLI publishing the same pattern from ffmpeg (src/native.ts), so the player grades a # native importer's timestamps: one rendition per codec, switched between, then AAC alone. "$action" "$engine: native publisher: opus + AAC" --native opus,mp4a --player-engine "$engine" --cases audio-switch "$action" "$engine: native publisher: AAC" --native mp4a --player-engine "$engine" --cases none # The fixture's audio-offset fault as ffmpeg's -itsoffset: OFFSET_STEPS (4) steps of 200 ms. "$action" "$engine: control: native offset audio" --native opus --native-offset 0.8 --player-engine "$engine" --cases none --expect-fail "audio/video sync" } # The native media publisher: ffmpeg's rendering of the media QA pattern, in each of , with # the audio delayed seconds, imported by the Rust CLI as . # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes run_native_media() { local codecs="$1" offset="$2" broadcast="$3" video audio connect video=$(cd "$CLIENTS/js" && bun src/native.ts video) || return 1 audio=$(cd "$CLIENTS/js" && bun src/native.ts audio) || return 1 rust_connect "$MEDIA_URL" ffmpeg_fmp4 "$audio" "$codecs" "$video" "$offset" | RUST_LOG="$CLI_LOG" "$MOQ" "${connect[@]}" --broadcast "$broadcast" import --max-age 5s fmp4 } # shellcheck disable=SC2329 # invoked indirectly via media_runs plan_media() { record "not run" case="$1" publisher="$(media_publisher "${@:2}")" subscriber=js } # The publisher a media run's name: the Rust CLI for a native run, else the browser fixture. # shellcheck disable=SC2329 # invoked indirectly via media_runs media_publisher() { case " $* " in *" --native "* | *" --publisher native "*) echo rust ;; *) echo js ;; esac } # shellcheck source-path=SCRIPTDIR source=socket.sh source "$INTEROP_DIR/socket.sh" # Record every case this run will reach as `not run`, before any build, so one the run # never reaches still shows in the report. Mirrors the dispatch at the end of this file. plan_cases() { local sub engine if [[ "$MEDIA" -eq 1 ]]; then for engine in "${MEDIA_ENGINE_LIST[@]}"; do media_runs plan_media "$engine"; done return fi if [[ "$NEGATIVE" -eq 1 ]]; then for sub in "${SUB_LIST[@]}"; do record "not run" case="no publisher" publisher=none subscriber="$sub"; done return fi if [[ "$SOCKET_ONLY" -eq 1 ]]; then plan_js_sockets return fi for VERSION in "${VERSION_LIST[@]}"; do plan_pass; done VERSION=auto } # One version's share of plan_cases. plan_pass() { local pub sub runtime version transport engine if [[ "$TAIL_ONLY" -eq 0 && "$FINISH_ONLY" -eq 0 ]]; then for pub in "${PUB_LIST[@]}"; do for sub in "${SUB_LIST[@]}"; do record "not run" case=media publisher="$pub" subscriber="$sub"; done [[ "$IN_TREE" -eq 0 ]] || record "not run" case="relay close" publisher="$pub" [[ "$VERSION" == auto ]] || record "not run" case="negotiated version" publisher="$pub" ! prints_grant "$pub" || record "not run" case=grant publisher="$pub" done for pub in "${PUB_LIST[@]}"; do enforces_grant "$pub" || continue for sub in "${SUB_LIST[@]}"; do record "not run" case=unauthorized publisher="$pub" subscriber="$sub"; done record "not run" case="unauthorized publisher" publisher="$pub" done if [[ "$AUDIO" -eq 1 && "$VERSION" == auto ]]; then for sub in "${SUB_LIST[@]}"; do record "not run" case="wrong tone" publisher=rust subscriber="$sub"; done fi if [[ "$VERSION" == auto ]] && needs js; then record "not run" case="refused session" subscriber=js for engine in "${ROUTE_KEY_ENGINES[@]}"; do record "not run" case="route key $engine" subscriber=js version=moq-lite-07-wip done fi fi [[ "$IN_TREE" -eq 1 ]] || return 0 if [[ "$TAIL_ONLY" -eq 0 ]]; then for pub in "${PUB_LIST[@]}"; do for sub in "${SUB_LIST[@]}"; do record "not run" case=finish publisher="$pub" subscriber="$sub"; done done fi [[ "$FINISH_ONLY" -eq 0 ]] || return 0 if needs python; then [[ "$VERSION" != auto ]] || record "not run" case="Python Rust sockets" publisher=python subscriber=rust fi for pub in rust js-native-node js-native-bun; do [[ "$pub" == rust ]] || needs "$pub" || continue for sub in rust js-native-node js-native-bun; do [[ "$sub" == rust ]] || needs "$sub" || continue record "not run" case="CLI FETCH" publisher="$pub" subscriber="$sub" done done record "not run" case="late join" publisher=rust subscriber=rust record "not run" case=tail publisher=rust subscriber=rust for runtime in js-native-node js-native-bun; do needs "$runtime" || continue for sub in rust js-native-node js-native-bun; do [[ "$sub" == rust ]] || needs "$sub" || continue record "not run" case="CLI FETCH history" publisher="$runtime" subscriber="$sub" done record "not run" case="late join" publisher=rust subscriber="$runtime" record "not run" case="late join" publisher="$runtime" subscriber=rust record "not run" case=tail publisher=rust subscriber="$runtime" record "not run" case=tail publisher="$runtime" subscriber=rust if [[ "$VERSION" == moq-transport-* ]]; then record "not run" case=datagram publisher=rust subscriber="$runtime" record "not run" case=datagram publisher="$runtime" subscriber=rust record "not run" case="wire track status" publisher=rust subscriber="$runtime" record "not run" case=goaway publisher=rust subscriber="$runtime" [[ "$runtime" != js-native-bun ]] || record "not run" case=goaway publisher="$runtime" subscriber=rust record "not run" case="track status" publisher="$runtime" subscriber=rust fi done [[ "$VERSION" == auto ]] || return 0 plan_js_sockets for runtime in js-native-node js-native-bun; do needs "$runtime" || continue for version in "${COMPRESS_VERSIONS[@]}"; do record "not run" case=compress publisher=rust subscriber="$runtime" version="$version" record "not run" case=compress publisher="$runtime" subscriber=rust version="$version" done record "not run" case="compressed catalog" publisher=rust subscriber="$runtime" record "not run" case="hls grid" publisher="$runtime" subscriber=rust for transport in websocket webtransport; do record "not run" case="track replacement $transport" publisher="$runtime" subscriber="$runtime" version=moq-lite-07-wip done if [[ "$COMPAT" -eq 0 ]]; then for transport in websocket webtransport; do record "not run" case="claims $transport" publisher="$runtime" subscriber="$runtime" version=moq-lite-07-wip done fi done needs js-native-node || return 0 for version in "${DROP_VERSIONS[@]}"; do record "not run" case=drop publisher=rust subscriber=js-native-node version="$version" record "not run" case=drop publisher=js-native-node subscriber=rust version="$version" done } plan_cases TARGET_BASE="" # cargo target dir (resolved in require_tools) EXAMPLES="$HARNESS_RUN/examples" 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 mark_broken() { # A client whose source build fails fails only its own matrix cells instead # of aborting the whole run, so one broken binding still lets the rest report. BROKEN_LANGS="$BROKEN_LANGS $1" echo " WARN $1 client unavailable: $2" } is_broken() { local lang="$1" x for x in $BROKEN_LANGS; do [[ "$x" == "$lang" ]] && return 0; done return 1 } have() { command -v "$1" >/dev/null 2>&1; } require_tools() { # The relay, CLI, ffmpeg, and harness essentials are hard requirements. A # missing per-client toolchain (uv / bun / node / cc) just marks that client # broken in prepare, so it fails its own cells instead of the whole run. # Tail-only runs publish raw tracks, so they never encode with ffmpeg. local missing=() t tools=(cargo curl timeout) [[ "$TAIL_ONLY" -eq 1 ]] || tools+=(ffmpeg) for t in "${tools[@]}"; do have "$t" || missing+=("$t") done if [[ ${#missing[@]} -gt 0 ]]; then echo "error: missing required tools: ${missing[*]}" >&2 exit 1 fi # Resolve the cargo target dir once (honors a custom CARGO_TARGET_DIR, which # the self-hosted CI runner sets), so the built binaries and moq-c's header # are found wherever cargo actually writes them. TARGET_BASE=$(cargo metadata --format-version 1 --manifest-path "$WORKSPACE/Cargo.toml" --no-deps | sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p') [[ -n "$TARGET_BASE" ]] || { echo "error: could not resolve cargo target directory" >&2 exit 1 } } # Build moq-relay + moq-cli from the workspace. The relay is the spine of the # test, so a failure here aborts rather than marking a single client broken. build_relay_cli() { local flag=() build_examples=0 [[ "$PROFILE" == "release" ]] && flag=(--release) if [[ -n "$RELAY" && -n "$MOQ" ]]; then [[ -x "$RELAY" && -x "$MOQ" ]] || { echo "override binaries are not executable" >&2 exit 1 } else build_examples=1 echo "building moq-relay + moq-cli ($PROFILE)..." # ${arr[@]+...} guard: bash 3.2 (macOS /bin/bash) errors on "${flag[@]}" for # an empty (debug) array under `set -u`. (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-relay -p moq-cli) || { echo "error: failed to build moq-relay / moq-cli" >&2 exit 1 } [[ -n "$RELAY" ]] || RELAY="$TARGET_BASE/$PROFILE/moq-relay" # The `moq-cli` crate ships its binary as `moq` (a `[[bin]]` override). [[ -n "$MOQ" ]] || MOQ="$TARGET_BASE/$PROFILE/moq" fi harness_stage bin/moq-relay "$RELAY" harness_stage bin/moq "$MOQ" RELAY="$HARNESS_RUN/bin/moq-relay" MOQ="$HARNESS_RUN/bin/moq" if [[ -n "${INTEROP_SUB_MOQ:-}" ]]; then harness_stage bin/moq-sub "$INTEROP_SUB_MOQ" INTEROP_SUB_MOQ="$HARNESS_RUN/bin/moq-sub" fi if [[ "$build_examples" -eq 1 && "$MEDIA" -eq 0 ]]; then local example args=() examples=(interop-socket interop-tail interop-socket-js interop-catalog interop-audio interop-hls interop-control interop-goaway interop-goaway-client interop-finish) for example in "${examples[@]}"; do args+=(--example "$example"); done (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-cli "${args[@]}") for example in "${examples[@]}"; do harness_stage "examples/$example" "$TARGET_BASE/$PROFILE/examples/$example" done fi } # Build the workspace Python packages as wheels (maturin builds rs/moq-ffi, hatchling # the moq-rs wrapper) and install them into a venv in the run directory, so `import moq` # resolves to this checkout rather than a PyPI wheel. The shared .venv is off limits: a # concurrent run's `just py build` uninstalls the package there while this run's # clients are importing it. prepare_python() { have uv || { mark_broken python "uv not found" return } echo "building python client (workspace moq wheels)..." local venv="$HARNESS_RUN/py-venv" wheels="$HARNESS_RUN/py-wheels" # maturin stages the bindings at a fixed path under the cargo target dir, so one # build at a time per target. Debug like the other clients; its build backend # defaults to release. if (cd "$WORKSPACE" && harness_locked "$TARGET_BASE/.moq-test-maturin.lock" \ env MATURIN_PEP517_ARGS="--profile dev --locked" \ uv build --wheel --package moq-ffi --out-dir "$wheels" && uv build --wheel --package moq-rs --out-dir "$wheels" && uv venv "$venv" && uv pip install --python "$venv/bin/python" --no-deps "$wheels"/*.whl) >"$HARNESS_RUN/py-build.log" 2>&1; then PY="$venv/bin/python" else mark_broken python "wheel build failed" sed 's/^/ /' "$HARNESS_RUN/py-build.log" >&2 || true fi } # Link the JS workspace (the interop clients are bun workspace members, so the # @moq/* packages resolve to this checkout's source) and build the browser page. prepare_js() { have bun || { for v in js js-native js-native-node js-native-bun; do needs "$v" && mark_broken "$v" "bun not found"; done return } # A staged released-compat client brings its own packages. if [[ -n "${INTEROP_COMPAT_CLIENT:-}" ]]; then [[ -f "$INTEROP_COMPAT_CLIENT/client.ts" ]] || { echo "missing staged compat client" >&2 exit 1 } return fi echo "installing js clients (workspace @moq/* via bun)..." if ! uv run --no-project python "$WORKSPACE/sh/js/install.py" frozen >"$HARNESS_RUN/js-install.log" 2>&1; then for v in js js-native js-native-node js-native-bun; do needs "$v" && mark_broken "$v" "bun install failed"; done sed 's/^/ /' "$HARNESS_RUN/js-install.log" >&2 || true return fi if needs js; then if ! (cd "$CLIENTS/js" && bun run check) >"$HARNESS_RUN/js-check.log" 2>&1; then mark_broken js "type check failed" sed 's/^/ /' "$HARNESS_RUN/js-check.log" >&2 || true # Into the run directory, where harness.ts serves it from: vite empties its output # first, so a shared dist/ vanishes under a concurrent run's page loads. elif ! (cd "$CLIENTS/js" && bunx vite build --outDir "$HARNESS_RUN/js-dist" --emptyOutDir) >"$HARNESS_RUN/js-vite.log" 2>&1; then mark_broken js "vite build failed" sed 's/^/ /' "$HARNESS_RUN/js-vite.log" >&2 || true fi fi if (needs js-native-node || needs js-native-bun) && ! (cd "$CLIENTS/js-native" && bun run check) >"$HARNESS_RUN/js-native-check.log" 2>&1; then for v in js-native-node js-native-bun; do needs "$v" && mark_broken "$v" "type check failed"; done sed 's/^/ /' "$HARNESS_RUN/js-native-check.log" >&2 || true fi if needs js-native-node && ! have node; then mark_broken js-native-node "node not found" fi } # Stage the Go modules from this checkout (sh/go/stage.sh builds moq-ffi for # the host, regenerates the bindings, and wires the wrapper to them by replace), # then build the interop client against that exact tree. The client is copied to a # scratch dir first so the committed go.mod keeps its placeholder require; every # dependency resolves to a local directory, so nothing hits the module proxy. prepare_go() { have go || { mark_broken go "go not found" return } have uniffi-bindgen-go || { mark_broken go "uniffi-bindgen-go not found (see go/ffi/README.md)" return } echo "building go client (workspace moq-go via uniffi-bindgen-go)..." local staged ffi_pkg wrapper_pkg src="$HARNESS_RUN/go-client" if ! staged=$(bash "$WORKSPACE/sh/go/stage.sh" --output "$HARNESS_RUN/go-stage" 2>"$HARNESS_RUN/go-stage.log"); then mark_broken go "sh/go/stage.sh failed" sed 's/^/ /' "$HARNESS_RUN/go-stage.log" >&2 || true return fi ffi_pkg=$(printf '%s\n' "$staged" | sed -n 1p) wrapper_pkg=$(printf '%s\n' "$staged" | sed -n 2p) mkdir -p "$src" cp "$CLIENTS/go/go.mod" "$CLIENTS/go/main.go" "$src/" GO_INTEROP="$HARNESS_RUN/go-interop" if ! ( cd "$src" export CGO_ENABLED=1 GOFLAGS=-mod=mod go mod edit \ -replace="moq.dev/moq=$wrapper_pkg" \ -replace="moq.dev/moq-ffi=$ffi_pkg" # Scratch copy is outside the git tree; stamping VCS info fails in a worktree. go build -buildvcs=false -o "$GO_INTEROP" . ) >"$HARNESS_RUN/go-build.log" 2>&1; then mark_broken go "go build failed" sed 's/^/ /' "$HARNESS_RUN/go-build.log" >&2 || true fi } # Build and install cpp/moq (cargo builds moq-ffi, uniffi-bindgen-cpp renders the # bindings), then build the C++ client against the installed package with # find_package(moq-cpp), the way an external project consumes it. Debug, like the # rest of the run. prepare_cpp() { local t for t in cmake uniffi-bindgen-cpp; do have "$t" || { mark_broken cpp "$t not found (see cpp/moq/README.md)" return } done echo "building c++ client (workspace cpp/moq via uniffi-bindgen-cpp + cmake)..." local build="$HARNESS_RUN/cpp-build" prefix="$HARNESS_RUN/cpp-prefix" config=Debug [[ "$PROFILE" == "release" ]] && config=Release if ! { cmake -S "$WORKSPACE/cpp/moq" -B "$build/package" -DCMAKE_BUILD_TYPE="$config" -DCMAKE_INSTALL_LIBDIR=lib && cmake --build "$build/package" && cmake --install "$build/package" --prefix "$prefix" && cmake -S "$CLIENTS/cpp" -B "$build/client" -DCMAKE_BUILD_TYPE="$config" -DCMAKE_PREFIX_PATH="$prefix" && cmake --build "$build/client" } >"$HARNESS_RUN/cpp-build.log" 2>&1; then mark_broken cpp "cmake build failed" sed 's/^/ /' "$HARNESS_RUN/cpp-build.log" >&2 || true return fi CPP_INTEROP="$build/client/cpp-interop" } # Build moq-c (the C staticlib + cbindgen header) and compile the C subscriber # against it. cargo writes libmoq.a to the profile dir, and build.rs writes # moq.h into its OUT_DIR, which only cargo's JSON messages name. prepare_c() { local cc="${CC:-cc}" out_dir header lib os_libs have "$cc" || { mark_broken c "no C compiler ($cc) on PATH" return } echo "building c client (workspace moq-c + cc)..." local flag=() [[ "$PROFILE" == "release" ]] && flag=(--release) if ! (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-c --message-format=json-render-diagnostics) >"$HARNESS_RUN/c-build.json" 2>"$HARNESS_RUN/c-build.log"; then mark_broken c "cargo build -p moq-c failed" sed 's/^/ /' "$HARNESS_RUN/c-build.log" >&2 || true return fi out_dir=$(grep '"reason":"build-script-executed"' "$HARNESS_RUN/c-build.json" | grep -F '/moq-c#' | sed -n 's/.*"out_dir":"\([^"]*\)".*/\1/p' | tail -1) || true header="$out_dir/include/moq.h" lib="$TARGET_BASE/$PROFILE/libmoq.a" [[ -f "$header" && -f "$lib" ]] || { mark_broken c "moq-c artifacts missing ($header / $lib)" return } harness_stage c/include/moq.h "$header" harness_stage c/libmoq.a "$lib" # cargo can't inject libmoq.a's native deps into an external link, so read # them from the same list moq-c.pc and CMake use. local native_libs case "$(uname -s)" in Darwin) native_libs="$WORKSPACE/rs/moq-c/native-libs/apple.txt" ;; *) native_libs="$WORKSPACE/rs/moq-c/native-libs/linux.txt" ;; esac os_libs=() while read -r entry; do case "$entry" in '' | '#'*) continue ;; framework:*) os_libs+=(-framework "${entry#framework:}") ;; *) os_libs+=("-l$entry") ;; esac done <"$native_libs" C_INTEROP="$HARNESS_RUN/c-interop" if ! "$cc" "$CLIENTS/c/subscribe.c" -I"$HARNESS_RUN/c/include" "$HARNESS_RUN/c/libmoq.a" "${os_libs[@]}" -o "$C_INTEROP" >"$HARNESS_RUN/c-compile.log" 2>&1; then mark_broken c "cc compile failed" sed 's/^/ /' "$HARNESS_RUN/c-compile.log" >&2 || true fi } # Build the moq-gst plugin and confirm it loads against the GStreamer in the # environment. moqsrc links the host's libgstreamer, so this wants a real # GStreamer with the core plugins (the nix devShell ships gstreamer + base/good; # a bare shell without it marks gst unavailable). Sets GST_PLUGIN_DIR to the dir # holding libgstmoq.{so,dylib}. Subscribe only: moqsink publishing needs an # encoder + request-pad muxing this client doesn't drive. prepare_gst() { have gst-launch-1.0 || { mark_broken gst "gst-launch-1.0 not on PATH (needs a system GStreamer)" return } have gst-inspect-1.0 || { mark_broken gst "gst-inspect-1.0 not on PATH" return } echo "building gstreamer client (workspace moq-gst plugin)..." local flag=() [[ "$PROFILE" == "release" ]] && flag=(--release) if ! (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-gst) >"$HARNESS_RUN/gst-build.log" 2>&1; then mark_broken gst "cargo build -p moq-gst failed" sed 's/^/ /' "$HARNESS_RUN/gst-build.log" >&2 || true return fi local extension=so [[ "$(uname -s)" != Darwin ]] || extension=dylib harness_stage "gst/libgstmoq.$extension" "$TARGET_BASE/$PROFILE/libgstmoq.$extension" GST_PLUGIN_DIR="$HARNESS_RUN/gst" # Use the packaging smoke check's isolated discovery and factory assertions. if ! "$WORKSPACE/rs/moq-gst/smoke.sh" "$GST_PLUGIN_DIR" >"$HARNESS_RUN/gst-inspect.log" 2>&1; then mark_broken gst "moqsrc not exposed (plugin failed to load against this GStreamer)" sed 's/^/ /' "$HARNESS_RUN/gst-inspect.log" >&2 || true return fi # The audio check decodes through these, from the system's plugins. local factory for factory in decodebin opusdec audioconvert identity filesink; do GST_REGISTRY_1_0="$HARNESS_RUN/gst-run-registry.bin" gst-inspect-1.0 "$factory" >/dev/null 2>&1 || { mark_broken gst "GStreamer has no $factory" return } done for factory in fdkaacdec avdec_aac faad ""; do [[ -n "$factory" ]] || { mark_broken gst "GStreamer has no AAC decoder (fdkaacdec, avdec_aac, or faad)" return } GST_REGISTRY_1_0="$HARNESS_RUN/gst-run-registry.bin" gst-inspect-1.0 "$factory" >/dev/null 2>&1 && break done } # ── setup ─────────────────────────────────────────────────────────────────── require_tools build_relay_cli echo "relay: $RELAY" 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 # Held for the rest of the run, so a concurrent harness cannot pick the same # number between here and the relay's bind. harness_port relay "$PORT" PORT="$HARNESS_PORT" URL="http://127.0.0.1:${PORT}" # The reservation covers other harness runs, not the rest of the machine, so # still refuse a port some unrelated process is already serving on. if harness_probe "$URL/certificate.sha256"; then echo "error: something is already listening on 127.0.0.1:${PORT} (stale relay?)" >&2 exit 1 fi # The relay's auth server: `moq auth serve` verifying every token this run mints # against one fresh key. It answers only POST, so readiness is any HTTP reply. if [[ "$COMPAT" -eq 0 ]]; then harness_port auth AUTH_URL="http://127.0.0.1:${HARNESS_PORT}/" auth_up() { curl -s -o /dev/null --max-time 1 "$AUTH_URL"; } if auth_up; then echo "error: something is already listening on $AUTH_URL" >&2 exit 1 fi KEY="$HARNESS_RUN/key.jwk" "$MOQ" auth generate --out "$KEY" harness_spawn auth "$HARNESS_RUN/auth.log" "$MOQ" auth serve --listen "127.0.0.1:${HARNESS_PORT}" --key "$KEY" AUTH_PID="$HARNESS_PID" deadline=$((SECONDS + 30)) until auth_up; do if ((SECONDS >= deadline)) || harness_exited "$AUTH_PID"; then echo "auth server never became ready" >&2 sed 's/^/ auth: /' "$HARNESS_RUN/auth.log" >&2 || true exit 1 fi sleep 0.05 done fi echo "starting relay on 127.0.0.1:${PORT}..." # interop.toml is the source of truth; rewrite its ports into a scratch copy so the # committed file never has to be edited for a run. if [[ "$COMPAT" -eq 1 ]]; then # Anonymous, offering only the pinned version, which comes from the executable's # advertised CLI choices. sed -e "s|:4443\"|:${PORT}\"|g" -e '/^version = \[/,/^\]/d' -e 's|^url = "http://127.0.0.1:4440/"|public = "**"|' \ "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" sed -i "/\[listen\]/a version = [\"${INTEROP_VERSION}\"]" "$HARNESS_RUN/relay.toml" [[ "$(grep -c '^version = ' "$HARNESS_RUN/relay.toml")" == 1 && "$(grep -c '^public = ' "$HARNESS_RUN/relay.toml")" == 1 ]] || { echo "relay.toml needs exactly one pinned [listen] version and anonymous access" >&2 exit 1 } else sed -e "s|:4443\"|:${PORT}\"|g" -e "s|http://127.0.0.1:4440/|${AUTH_URL}|" \ "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" # The compressed-catalog lane reads the relay's inflate counter from /metrics, which counts # only while stats are enabled. harness_port metrics METRICS_URL="http://127.0.0.1:${HARNESS_PORT}/metrics" printf '\n[internal]\nlisten = "127.0.0.1:%s"\n\n[stats]\nenabled = true\n' "$HARNESS_PORT" >>"$HARNESS_RUN/relay.toml" fi # Connection spans and clean closes must survive an inherited warn-only filter. The route key lane # reads the relay's debug line for each verified proof. harness_spawn relay "$HARNESS_RUN/relay.log" env \ "RUST_LOG=${RUST_LOG:+$RUST_LOG,}moq_relay::connection=info,moq_relay::relay=info,moq_net::lite::setup=debug" \ "$RELAY" "$HARNESS_RUN/relay.toml" if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then echo "relay never became ready" >&2 sed 's/^/ relay: /' "$HARNESS_RUN/relay.log" >&2 || true exit 1 fi harness_endpoint relay "$URL" # shellcheck source-path=SCRIPTDIR source=relay-log.sh source "$INTEROP_DIR/relay-log.sh" # ── tokens ────────────────────────────────────────────────────────────────── # Print the relay URL carrying a fresh token; the arguments are `moq auth sign`'s # (`--publish P`, `--subscribe S`). Compat runs anonymous, so it prints the bare URL. token_url() { if [[ "$COMPAT" -eq 1 ]]; then printf '%s' "$URL" return fi local token token=$("$MOQ" auth sign --key "$KEY" "$@") printf '%s/?jwt=%s' "$URL" "$token" } # Set `connect` to moq-cli's arguments for , moving a `?jwt=` token to # `--connect-token` so it rides SETUP where the version carries one. The binding clients keep it # in the URL, which covers the query form. # shellcheck disable=SC2329 # invoked from the indirectly invoked publisher and subscriber rust_connect() { case "$1" in *'?jwt='*) connect=(--connect "${1%%\?jwt=*}" --connect-token "${1#*\?jwt=}") ;; *) connect=(--connect "$1") ;; esac } # The pattern a cell's token grants its publisher, or its subscribers, on . The `auto` # pass mints patterns no prefix could carry, so AUTH must deliver them as minted. moq-transport's # AUTH extension carries prefixes alone and refuses a narrower grant, closing the # session, so a pinned pass grants the broadcast's subtree. publish_grant() { if [[ "$VERSION" == auto ]]; then printf '%s' "$1"; else printf '%s/**' "$1"; fi } subscribe_grant() { if [[ "$VERSION" == auto ]]; then printf '**/%s' "$1"; else printf '%s/**' "$1"; fi } # moq-cli logs each grant it receives over AUTH at debug, under `moq_net::auth`. CLI_LOG="${RUST_LOG:-info},moq_net::auth=debug" # The lite version with AUTH, which no client offers by default. The clients the # harness reads a grant or a refusal from dial it. Compat dials its pinned version. AUTH_VERSION="${INTEROP_VERSION:-moq-lite-07-wip}" # The `auth granted` line a token minted with at most one publish and one # subscribe pattern implies. Every client that prints one dials $AUTH_VERSION, # which carries AUTH, so a client that prints nothing never got its grant. grant_line() { local publish="${1:+\"$1\"}" subscribe="${2:+\"$2\"}" printf 'auth granted publish=[%s] subscribe=[%s]' "$publish" "$subscribe" } # The last grant a client printed to , with ANSI colour dropped and list # separators normalized so the Rust and JS renderings compare equal. reported_grant() { sed 's/\x1b\[[0-9;]*m//g' "$1" 2>/dev/null | grep -o 'auth granted publish=\[[^]]*\] subscribe=\[[^]]*\]' | tail -n 1 | sed 's/", "/","/g' || true } # Check that 's log reports ; prints why not and fails otherwise. check_grant() { local lang="$1" log="$2" expected="$3" got prints_grant "$lang" || return 0 got=$(reported_grant "$log") [[ "$got" == "$expected" ]] && return 0 echo "grant: got '${got:-nothing}', token implies '$expected'" return 1 } # ── client dispatch ───────────────────────────────────────────────────────── # Encode an endless H.264 Annex-B stream from a synthetic source to stdout. # Paced with -re so the broadcast streams in real time until the reader closes. # Baseline + repeat-headers re-emits SPS/PPS before every keyframe so a late # subscriber (or the stream importer) can initialize without the first packet. # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes ffmpeg_h264() { ffmpeg -hide_banner -loglevel error -re -f lavfi -i "testsrc=size=${SIZE}:rate=${FPS}" \ -an -c:v libx264 -profile:v baseline -preset ultrafast -pix_fmt yuv420p \ -x264-params "keyint=${FPS}:min-keyint=${FPS}:scenecut=0:repeat-headers=1" \ -f h264 - } # An ffmpeg audio source playing : one 0.5-amplitude sine per channel, at 48 kHz. # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes tone_source() { local hz tones exprs=() layout=mono IFS=',' read -r -a tones <<<"$1" for hz in "${tones[@]}"; do exprs+=("0.5*sin(2*PI*$hz*t)"); done [[ "${#exprs[@]}" -eq 1 ]] || layout=stereo local IFS='|' printf 'aevalsrc=%s:s=48000:c=%s' "${exprs[*]}" "$layout" } # ffmpeg_fmp4