diff --git a/.github/workflows/energy-hardware.yml b/.github/workflows/energy-hardware.yml index f16020c42..97efed37e 100644 --- a/.github/workflows/energy-hardware.yml +++ b/.github/workflows/energy-hardware.yml @@ -36,6 +36,12 @@ on: - 'sandbox/sandbox-rs/crates/sandbox-proof/**' - 'proof/proof-rs/crates/proof-cascade/**' - 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**' + # The sink's readers and its table procedure (wai/extensions/sink-energy.md §4.4). + - 'wai/wai-rs/src/sink_meter.rs' + - 'wai/wai-rs/src/sink_energy.rs' + - 'wai/wai-rs/src/sink_energy/**' + - 'wai/wai-rs/src/bin/wai_sink_table.rs' + - 'wai/live-conformance/sink_energy_verify.py' - '.github/workflows/energy-hardware.yml' push: branches: @@ -48,6 +54,12 @@ on: - 'sandbox/sandbox-rs/crates/sandbox-proof/**' - 'proof/proof-rs/crates/proof-cascade/**' - 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**' + # The sink's readers and its table procedure (wai/extensions/sink-energy.md §4.4). + - 'wai/wai-rs/src/sink_meter.rs' + - 'wai/wai-rs/src/sink_energy.rs' + - 'wai/wai-rs/src/sink_energy/**' + - 'wai/wai-rs/src/bin/wai_sink_table.rs' + - 'wai/live-conformance/sink_energy_verify.py' - '.github/workflows/energy-hardware.yml' jobs: @@ -132,3 +144,101 @@ jobs: CARGO_TARGET_DIR="$HOME/hw-target/jouleclaw-rs" nice -n 10 cargo test -p jouleclaw-energy --features rapl,nvml --test rapl_hardware --test nvml_hardware -- --nocapture --test-threads=1 2>&1 | tee "$log" grep -q 'package-only inner' "$log" || { echo "::error::the RAPL live path never ran"; exit 1; } grep -q 'nvml hardware: 1 GPU' "$log" || { echo "::error::NVML saw no GPU"; exit 1; } + + # The sink's decode table (wai/extensions/sink-energy.md §4.4) on the two platforms whose + # counters energy-measurement §9 profiles. Each job surveys its runner's counters first. + # Where no sink profile is live there, the measurement step is skipped — shown as skipped, + # with the survey in the run's summary — and never reported as passed. Where one is, the + # procedure takes seven rounds of one work item (the committed int_motion vector) and + # writes a signed table with its readings and procedure, which the reference checks as it + # writes it and the independent verifier checks again. No figure is asserted: a busy + # runner gives an unmetered row with its reason, which is the procedure working, not + # failing (feedback on energy tests and host load: assert what the meter reports). + sink-table-linux: + name: sink decode table · RAPL on jetson-hub (NVML surveyed) + runs-on: linux-hw + timeout-minutes: 90 + env: + CARGO_PROFILE_RELEASE_DEBUG: '0' + CARGO_BUILD_JOBS: '8' + steps: + - name: Fetch the source + shell: bash + run: | + set -euo pipefail + ref="${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}" + want="${{ github.event.pull_request.head.sha || github.sha }}" + rm -rf src && mkdir src && cd src + git init -q + git fetch -q --depth 1 http://100.64.224.0:3400/Transaction-Science/open-standards.git "$ref" + git checkout -q FETCH_HEAD + got="$(git rev-parse HEAD)" + echo "fetched $ref at $got" + if [ "$got" != "$want" ]; then + echo "::error::$ref is at $got, not $want: it moved after this run started, and a newer run tests it" + exit 1 + fi + + - name: Survey the sink profiles + id: survey + shell: bash + working-directory: src/wai/wai-rs + run: | + set -euo pipefail + CARGO_TARGET_DIR="$HOME/hw-target/wai-rs" nice -n 10 cargo build --locked --release --no-default-features --features sink_energy,video_int --bin wai_sink_table + "$HOME/hw-target/wai-rs/release/wai_sink_table" survey | tee "$RUNNER_TEMP/survey.txt" + if grep -qE '^linux-rapl +live' "$RUNNER_TEMP/survey.txt"; then + echo "live=true" >> "$GITHUB_OUTPUT" + else + echo "live=false" >> "$GITHUB_OUTPUT" + { echo "### sink decode table: SKIPPED (not passed)"; echo; echo "No sink profile is live on this runner:"; echo '```'; cat "$RUNNER_TEMP/survey.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + echo "::warning::sink decode table SKIPPED: no live counter on this runner" + fi + + - name: Measure one work item and verify the table + if: steps.survey.outputs.live == 'true' + shell: bash + working-directory: src/wai/wai-rs + run: | + set -euo pipefail + out="$RUNNER_TEMP/sink-table" + rm -rf "$out" + nice -n 10 "$HOME/hw-target/wai-rs/release/wai_sink_table" run --out "$out" --rounds 7 --filter int_motion + python3 -I ../live-conformance/sink_energy_verify.py --table "$out" + + sink-table-macos: + name: sink decode table · per-task energy and the GPU rail on macOS + runs-on: macos + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + + - name: Survey the sink profiles + id: survey + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo build --locked --release --no-default-features --features sink_energy,video_int --bin wai_sink_table + bin="$(cargo metadata --format-version 1 --no-deps | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')/release/wai_sink_table" + echo "bin=$bin" >> "$GITHUB_OUTPUT" + "$bin" survey | tee "$RUNNER_TEMP/survey.txt" + if grep -qE '^macos-task +live' "$RUNNER_TEMP/survey.txt"; then + echo "live=true" >> "$GITHUB_OUTPUT" + else + echo "live=false" >> "$GITHUB_OUTPUT" + { echo "### sink decode table: SKIPPED (not passed)"; echo; echo "No sink profile is live on this runner:"; echo '```'; cat "$RUNNER_TEMP/survey.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + echo "::warning::sink decode table SKIPPED: no live counter on this runner" + fi + + - name: Measure one work item and verify the table + if: steps.survey.outputs.live == 'true' + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + out="$RUNNER_TEMP/sink-table" + rm -rf "$out" + "${{ steps.survey.outputs.bin }}" run --out "$out" --rounds 7 --filter int_motion --platform-decoder /nonexistent + python3 -I ../live-conformance/sink_energy_verify.py --table "$out" diff --git a/.github/workflows/joule-code-conformance.yml b/.github/workflows/joule-code-conformance.yml index 5e53fe191..099c4bc83 100644 --- a/.github/workflows/joule-code-conformance.yml +++ b/.github/workflows/joule-code-conformance.yml @@ -39,6 +39,16 @@ on: # joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors # and grants to the live-rooms corpus. - 'wai/live-conformance/rooms/**' + # joule-moq-ingest's tests/conformance_corpus.rs regenerates the Enhanced RTMP and the + # cue-injection corpora byte for byte, and holds these texts' reason tables to the codes + # it reports. + - 'wai/live-conformance/ertmp/**' + - 'wai/live-conformance/injection/**' + - 'wai/extensions/live-ingest.md' + - 'wai/extensions/cue-injection.md' + # joule-moq-ingest's tests/link_corpus.rs and its generator's --check read the + # contribution link records' corpus. + - 'wai/live-conformance/links/**' # joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs # read the end-to-end encrypted rooms corpus, and the latter that extension's §13. - 'wai/live-conformance/e2ee/**' @@ -46,6 +56,11 @@ on: # joule-moq's tests/rating_vectors.rs reads every rating claim and serving request of # the age-assurance corpus, and the relay's ratings are held to the same reading. - 'wai/live-conformance/age/**' + # joule-moq's tests/loc_vectors.rs regenerates and checks the LOC carriage corpus, from + # access units of the H.265 vector it reads, and requires loc-carriage §6's table. + - 'wai/live-conformance/loc/**' + - 'wai/format-probe-conformance/ext/hevc/**' + - 'wai/extensions/loc-carriage.md' - '.github/workflows/joule-code-conformance.yml' push: branches: @@ -62,6 +77,16 @@ on: # joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors # and grants to the live-rooms corpus. - 'wai/live-conformance/rooms/**' + # joule-moq-ingest's tests/conformance_corpus.rs regenerates the Enhanced RTMP and the + # cue-injection corpora byte for byte, and holds these texts' reason tables to the codes + # it reports. + - 'wai/live-conformance/ertmp/**' + - 'wai/live-conformance/injection/**' + - 'wai/extensions/live-ingest.md' + - 'wai/extensions/cue-injection.md' + # joule-moq-ingest's tests/link_corpus.rs and its generator's --check read the + # contribution link records' corpus. + - 'wai/live-conformance/links/**' # joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs # read the end-to-end encrypted rooms corpus, and the latter that extension's §13. - 'wai/live-conformance/e2ee/**' @@ -69,6 +94,11 @@ on: # joule-moq's tests/rating_vectors.rs reads every rating claim and serving request of # the age-assurance corpus, and the relay's ratings are held to the same reading. - 'wai/live-conformance/age/**' + # joule-moq's tests/loc_vectors.rs regenerates and checks the LOC carriage corpus, from + # access units of the H.265 vector it reads, and requires loc-carriage §6's table. + - 'wai/live-conformance/loc/**' + - 'wai/format-probe-conformance/ext/hevc/**' + - 'wai/extensions/loc-carriage.md' - '.github/workflows/joule-code-conformance.yml' jobs: @@ -111,14 +141,14 @@ jobs: $SUDO apt-get install -y -qq z3 z3 --version - # joule-moq-relay and joule-moq-ingest run in jobs of their own below, on - # linux-arm64: they need nothing x86-specific, and this runner is the one there is - # least of. + # joule-moq-relay, joule-moq-ingest and joule-contrib run in jobs of their own + # below, on linux-arm64: they need nothing x86-specific, and this runner is the one + # there is least of. - name: Reference library tests working-directory: joule-code/joule-code-rs env: JOULE_FIX_EXPECT_Z3: '1' - run: cargo test --workspace --exclude joule-moq-relay --exclude joule-moq-ingest + run: cargo test --workspace --exclude joule-moq-relay --exclude joule-moq-ingest --exclude joule-contrib # joule-moq's QUIC transport and its example compile only with `quic`. Named # here so they stay built and tested whatever the workspace's other members @@ -235,13 +265,45 @@ jobs: shell: bash run: cargo test --locked -p wtransport --lib - # The WHIP and WHEP gateway. Every test is on loopback, against a relay that checks - # every receipt at its door: a WHIP publisher (str0m as the client) of H.264 and Opus - # whose frames a relay subscriber receives whole with every receipt verified, and DELETE - # ending the tracks; a WHEP player that gets the same frames from the group it joined; - # VP8, VP9 and AV1 through WHIP; the refusals of RFC 9725 and RFC 6750; trickle ICE and - # an ICE restart with the media carried across it; and the room reader against the - # live-rooms corpus. `--all-targets` runs the unit tests and builds the binary. + # The relay's own energy on a counter (WAI live-metering §8): tests/relay_energy.rs on + # macOS, where the relay reads its process's per-task CPU energy unprivileged. The test + # is required here (JOULE_MOQ_RELAY_ENERGY_REQUIRE): a host with no counter fails it + # rather than skipping. On linux-arm64 above the same test prints SKIPPED, which is not a + # pass of the measurement: that VM has no counter the relay reads. + moq-relay-energy: + name: joule-moq-relay, its own energy on a counter (macos) + runs-on: macos + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: The room's record carries the relay's measured energy, and the marker where it is not the room's + working-directory: joule-code/joule-code-rs + shell: bash + env: + JOULE_MOQ_RELAY_ENERGY_REQUIRE: '1' + run: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo test --locked -p joule-moq-relay --test relay_energy -- --nocapture --test-threads=1 2>&1 | tee "$RUNNER_TEMP/relay-energy.log" + grep -q "test result: ok. 3 passed; 0 failed" "$RUNNER_TEMP/relay-energy.log" + grep -q "measured: .* scope task" "$RUNNER_TEMP/relay-energy.log" + ! grep -q "SKIPPED" "$RUNNER_TEMP/relay-energy.log" + + # The ingest gateway. Every test is on loopback, against a relay that checks every + # receipt at its door: a WHIP publisher (str0m as the client) of H.264 and Opus whose + # frames a relay subscriber receives whole with every receipt verified, and DELETE ending + # the tracks; a WHEP player that gets the same frames from the group it joined; VP8, VP9 + # and AV1 through WHIP; the refusals of RFC 9725 and RFC 6750; trickle ICE and an ICE + # restart with the media carried across it; the room reader against the live-rooms + # corpus; an Enhanced RTMP publisher of the tests' own over RTMP and RTMPS (every payload + # framing, multitrack layers, the stream key's refusals, the reconnect request); an SCTE + # 104 automation system of the tests' own driving the injector, the chain read at the + # relay; and the Enhanced RTMP and cue-injection corpora regenerated byte for byte. + # `--all-targets` runs the unit tests and builds the binary. tests/ertmp_ffmpeg.rs, which + # runs a local encoder as the test client, is ignored here: this runner has no encoder, + # and the run lists it as ignored, not passed. moq-ingest: name: joule-moq-ingest, every target runs-on: linux-arm64 @@ -264,3 +326,18 @@ jobs: working-directory: joule-code/joule-code-rs shell: bash run: cargo test --locked -p joule-moq-ingest --all-targets + + # SRT and RIST contribution (WAI extensions/live-contribution.md). joule-contrib's + # transports on loopback through lossy paths, each with and without the feature + # `rist` (off by default, its §10); the gateway's SRT and RIST sessions into a relay + # that checks every receipt; the link record against its corpus, which the generator + # regenerates and compares byte for byte. The tests against the reference tools are + # ignored here: they run where the tools are installed. + - name: joule-contrib and joule-moq-ingest with RIST, every target + working-directory: joule-code/joule-code-rs + shell: bash + run: | + cargo test --locked -p joule-contrib --all-targets + cargo test --locked -p joule-contrib --all-targets --features rist + cargo test --locked -p joule-moq-ingest --all-targets --features rist + cargo run --locked -q -p joule-moq-ingest --bin joule-moq-ingest-link-vectors -- --check ../../wai/live-conformance/links diff --git a/.github/workflows/joule-moq-remote.yml b/.github/workflows/joule-moq-remote.yml new file mode 100644 index 000000000..b61601291 --- /dev/null +++ b/.github/workflows/joule-moq-remote.yml @@ -0,0 +1,93 @@ +name: joule-moq-remote host agent + +# The remote desktop host agent (joule-code/joule-code-rs/crates/joule-moq-remote), a workspace +# of its own with its own lock file (WAI extensions/remote-desktop.md): its room files, the +# pure-function H.264 encoder (every macroblock I_PCM), the pattern source and the recorder, and +# tests/loopback.rs, a session over a relay on loopback: three viewers verify every frame against +# its receipt, a click under the floor applied, a viewer with no control power and a controller +# off the floor refused, the floor handed over, a paused screen refused, latency records signed, +# and the session's chains verified whole by wai-rs; and the gate's order, a participant's records +# taken in its chain's order as a room service's clock takes them; alice's typing sealed to the +# host's sealing key, injected as typed and absent from what a viewer receives, and the session +# verified at the opened level too; and tests/tls.rs, the clients of the room service over +# https:// and of the relay over wss:// through a TLS front, every certificate verified. The same +# host in a room the room service keeps is joule-moq-room's tests/remote_room.rs. On Linux arm64 the Linux adapters (X11 +# capture, the kernel's input device) compile and the session runs with the pure-function +# encoder; on macOS it runs again with the platform's hardware encoder, whose units are checked +# for the Annex B the capability carries. +# +# It builds on wai-rs's room and remote desktop records, on the relay and on joule-moq, so a +# change to any of them runs it. Push filters match the push's delta, so every path the job +# reads is listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - 'joule-code/joule-code-rs/crates/joule-moq-remote/**' + - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' + - 'joule-code/joule-code-rs/crates/jwp/**' + - 'joule-code/joule-code-rs/vendor/**' + - 'wai/wai-rs/**' + - 'wai/wai-ops/**' + - '.github/workflows/joule-moq-remote.yml' + +jobs: + # Forgejo matches `paths:` against the push delta, so a rebase fires this workflow + # whatever the pull request changes. This job reads the pull request's own diff against + # the paths above (scripts/pr-touches-paths.py, which fails open). + relevant: + name: joule-moq-remote · does this change touch it + runs-on: linux + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/joule-moq-remote.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches the host agent: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + test: + name: joule-moq-remote, every target (${{ matrix.os }}) + needs: relevant + if: needs.relevant.outputs.run == 'true' + strategy: + fail-fast: false + matrix: + os: [linux-arm64, macos] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # One test at a time: the sessions share the host with other jobs, and each takes a lock. + - name: joule-moq-remote, every target, and clippy + working-directory: joule-code/joule-code-rs/crates/joule-moq-remote + shell: bash + run: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo test --locked --all-targets -- --test-threads=1 + cargo clippy --locked --all-targets --no-deps -- -D warnings diff --git a/.github/workflows/joule-moq-room.yml b/.github/workflows/joule-moq-room.yml index 4690484ec..a59e4933d 100644 --- a/.github/workflows/joule-moq-room.yml +++ b/.github/workflows/joule-moq-room.yml @@ -12,11 +12,25 @@ name: joule-moq-room room service # Privacy Pass origin whose tokens' thresholds its grants state and the relay serves by; and a # watch room (WAI co-watch): viewers admitted by a proof of their key, their controls and # statements carried when co-watch accepts them, the clock's skew statements and leaves, and the -# room the log carries verified by wai-rs. +# room the log carries verified by wai-rs; and a remote desktop room (WAI remote-desktop, +# tests/remote_room.rs): the host agent of joule-moq-remote enters by an invitation over https:// +# and wss:// through TLS fronts, viewers make their keys, the host hands control over by a grant +# exchange it proves its key for, inputs are applied and refused by the rules, a controller's +# typing sealed to the host and absent from what a viewer receives, refused floor statements +# change nothing, a record out of its chain's order is cut and its author removed, the session's +# records are read under a grant of the room alone, and the session verified by wai-rs as the +# clock and the host hold it, at the commitment and the opened levels; a viewer flooding past its +# own record bound (max_records_per_principal), cut and removed while the session goes on and +# verifies; and a session the clock ends at its record bound on the whole session (max_records), +# whole and verified; a conversation (WAI live-messaging, tests/messaging_room.rs): +# members keying through real MLS groups, messaging across a restart of the service from its +# history store, a thread, reactions, an edit and a retraction, a removed member served nothing +# past its removal, every record verified offline; and a call (tests/call_room.rs) rung, accepted +# and carrying audio and video through the relay, and another missed. # -# It builds on wai-rs's room, event, composition and co-watch records, on the relay and on -# joule-moq-mls, so a change to any of them runs it. Push filters match the push's delta, so every path the job reads is -# listed. +# It builds on wai-rs's room, event, composition, co-watch and remote desktop records, on the +# relay, on joule-moq-mls and on the remote desktop host agent, so a change to any of them runs +# it. Push filters match the push's delta, so every path the job reads is listed. concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -26,6 +40,7 @@ on: pull_request: paths: - 'joule-code/joule-code-rs/crates/joule-moq-room/**' + - 'joule-code/joule-code-rs/crates/joule-moq-remote/**' - 'joule-code/joule-code-rs/crates/joule-moq/**' - 'joule-code/joule-code-rs/crates/joule-moq-mls/**' - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' diff --git a/.github/workflows/joule-moq-web.yml b/.github/workflows/joule-moq-web.yml index 8c8df8ec4..a7bb218e7 100644 --- a/.github/workflows/joule-moq-web.yml +++ b/.github/workflows/joule-moq-web.yml @@ -59,6 +59,37 @@ name: joule-moq-web browser client # pause and a seek applied by every page at their instants, a late joiner # brought to the room's position, the clock's skew statements in sync within # the bound, and the room verified by co_watch_verify.py. +# termination stream termination (WAI stream-termination, capability-constraints §4): +# live-conformance/termination in the page; then a receiving page driven over +# the DevTools protocol alone, so its tab really goes to the background: after +# the grace it signs a termination as hidden and cancels its video subscription +# while the audio goes on, the log verifies on the host in wai-rs and in the +# Python verifier, a segment receipt replayed after the termination is refused, +# the video is subscribed again under a new log once the tab is in front, a +# smaller screen is declared again with each receipt's match, and a canvas out +# of the viewport ends the video as occluded. +# remote a remote desktop room (WAI remote-desktop) the room service keeps: the host +# agent enters with an invitation, a page makes its key and is granted control, +# decodes the host's screen, asks for the floor, clicks and types, and signs +# latency records; a viewer's page sends nothing, and an input sent past its +# guard is refused by the host; and the sessions the host and the service's +# clock hold verified by remote_desktop_verify.py. +# world a room with a shared world (WAI live-worlds): two pages converge through the +# relay on one session hash, a late page fetches every world track from group 0, +# a viewer acts in nothing, the relay refuses what a grant does not allow, and +# its door drops a group receipted by another key. +# privateworld a private world (an end-to-end encrypted room): sealed world objects, the +# members' carries bring later members the world, a page outside the group +# opens nothing, and a member removed opens nothing after. +# messaging a conversation (WAI live-messaging) kept by a room service with a history store +# (e2e/messaging.e2e.mjs): three pages join and key through the service's log, +# message one another and open what the others seal; one page goes offline, the +# service restarts from its store, and the page catches up, the thread, the +# reaction, the edit and the retraction shown as verified; acknowledgements; a +# member removed opens nothing written after; the store holds no plaintext. +# call a call between two pages: the callee's inbox checks the invitation before it +# rings, the call is accepted and both rooms decode the other's audio and video; +# a call never answered is missed, signed by the room's clock. # # Before the browser, wai-live-web's own tests: the room's records a page reads, co-performance's # among them, each statement read and the corpus verified through the crate. @@ -97,11 +128,24 @@ on: - 'wai/live-conformance/age/**' - 'wai/live-conformance/coperformance/**' - 'wai/live-conformance/browser/**' + # The termination scenario runs this corpus in the page, and verifies the page's logs on + # the host with its second implementation, which reads the strict Ed25519 rule beside it. + - 'wai/live-conformance/termination/**' + - 'wai/live-conformance/stream_termination_verify.py' + - 'wai/claim-conformance/_ed25519.py' - 'wai/wai-rs/tests/vectors/int_video/**' # The page's co-watch module is tested against the co-watch corpus, and the cowatch # scenario verifies its room with the corpus's second implementation. - 'wai/live-conformance/cowatch/**' - 'wai/live-conformance/co_watch_verify.py' + # The page's remote desktop module is tested against the remote corpus, and the remote + # scenario runs the host agent and verifies its session with the corpus's second + # implementation. + - 'joule-code/joule-code-rs/crates/joule-moq-remote/**' + - 'wai/live-conformance/remote/**' + - 'wai/live-conformance/remote_desktop_verify.py' + # wai-live-web's own tests run the live-worlds corpus's accepted world through the page's path. + - 'wai/live-conformance/worlds/**' - '.github/workflows/joule-moq-web.yml' pull_request: paths: @@ -124,11 +168,24 @@ on: - 'wai/live-conformance/age/**' - 'wai/live-conformance/coperformance/**' - 'wai/live-conformance/browser/**' + # The termination scenario runs this corpus in the page, and verifies the page's logs on + # the host with its second implementation, which reads the strict Ed25519 rule beside it. + - 'wai/live-conformance/termination/**' + - 'wai/live-conformance/stream_termination_verify.py' + - 'wai/claim-conformance/_ed25519.py' - 'wai/wai-rs/tests/vectors/int_video/**' # The page's co-watch module is tested against the co-watch corpus, and the cowatch # scenario verifies its room with the corpus's second implementation. - 'wai/live-conformance/cowatch/**' - 'wai/live-conformance/co_watch_verify.py' + # The page's remote desktop module is tested against the remote corpus, and the remote + # scenario runs the host agent and verifies its session with the corpus's second + # implementation. + - 'joule-code/joule-code-rs/crates/joule-moq-remote/**' + - 'wai/live-conformance/remote/**' + - 'wai/live-conformance/remote_desktop_verify.py' + # wai-live-web's own tests run the live-worlds corpus's accepted world through the page's path. + - 'wai/live-conformance/worlds/**' - '.github/workflows/joule-moq-web.yml' jobs: @@ -179,7 +236,8 @@ jobs: working-directory: joule-code/joule-code-rs run: nice -n 10 cargo check --locked -p joule-moq --no-default-features # The page's records library, natively: its co-performance reading checked against the - # corpus live-conformance/coperformance (src/co.rs) beside its own cases. + # corpus live-conformance/coperformance (src/co.rs) and its stream logs against + # live-conformance/termination (src/termination_tests.rs), beside its own cases. - name: wai-live-web, its own tests shell: bash working-directory: wai/wai-live-web @@ -197,4 +255,6 @@ jobs: nice -n 10 bash joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/moq-web-e2e.log" grep -qE '^room e2e: 1 of 1 runs passed; ' "$RUNNER_TEMP/moq-web-e2e.log" \ || { echo "::error::the browser end-to-end run did not pass"; exit 1; } + grep -qE '^messaging e2e: 1 of 1 runs passed; ' "$RUNNER_TEMP/moq-web-e2e.log" \ + || { echo "::error::the messaging and call end-to-end run did not pass"; exit 1; } if grep -q '^FAIL' "$RUNNER_TEMP/moq-web-e2e.log"; then echo "::error::a check failed"; exit 1; fi diff --git a/.github/workflows/wai-audit.yml b/.github/workflows/wai-audit.yml new file mode 100644 index 000000000..7f9aecf07 --- /dev/null +++ b/.github/workflows/wai-audit.yml @@ -0,0 +1,136 @@ +name: wai-audit collector and player integration + +# WAI's stream audit kit (wai/wai-audit, extensions/stream-audit.md): the collector's tests (every +# session of the corpus live-conformance/audit posted gets the corpus's outcome, and the stored +# sessions report exactly the corpus's served reports, byte for byte and signature; CORS, body +# limits, per-client limits and CMCD intake), clippy with warnings as errors, on Linux and on the +# macOS runner; and on the macOS runner, the host with the browser installed, the player +# integration's unit tests against the corpus and the browser end to end (e2e/run.sh): an HLS +# stream made with the host's media tool, served from another origin, played through an +# open-source web player twice (its loader adapter, and the network tap), each session verified, +# stored and reported by a collector on loopback, and the report recomputed from the bundles. +# The packages come from e2e/package-lock.json with install scripts off. Nothing listens beyond +# loopback. +# +# Push filters match the push's delta, so every path the jobs read is listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-audit/**' + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/live-conformance/audit/**' + - 'wai/live-conformance/native-sink/**' + - 'wai/extensions/stream-audit.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-audit.yml' + pull_request: + paths: + - 'wai/wai-audit/**' + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/live-conformance/audit/**' + - 'wai/live-conformance/native-sink/**' + - 'wai/extensions/stream-audit.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-audit.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-audit.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-audit: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + collector-linux: + name: collector · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy + shell: bash + working-directory: wai/wai-audit + run: | + set -euo pipefail + cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + cargo clippy --locked --release --all-targets -- -D warnings + + kit-macos: + name: collector, player integration and the browser end to end · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo and the host's packages (the media tool, node) on the macOS + # host runner. + - name: Put cargo and the host's tools on PATH + shell: bash + run: | + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + echo "/opt/homebrew/bin" >> "$GITHUB_PATH" + - name: The collector's tests + shell: bash + working-directory: wai/wai-audit + run: | + set -euo pipefail + nice -n 10 cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + - name: The player integration and the browser end to end + shell: bash + env: + BROWSER_BIN: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome # allow-name: the runner host's installed browser, by its path + AUDIT_E2E_OUT: ${{ runner.temp }}/wai-audit-e2e + run: | + set -euo pipefail + test -x "$BROWSER_BIN" || { echo "::error::this runner has no browser at $BROWSER_BIN"; exit 1; } + command -v ffmpeg >/dev/null || { echo "::error::this runner has no media tool to make the stream with"; exit 1; } + nice -n 10 bash wai/wai-audit/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/audit-e2e.log" + grep -qE '^ℹ pass [1-9][0-9]*$' "$RUNNER_TEMP/audit-e2e.log" && ! grep -qE '^ℹ fail [1-9]' "$RUNNER_TEMP/audit-e2e.log" \ + || { echo "::error::the player integration's unit tests did not pass"; exit 1; } + grep -q '^all checks ok$' "$RUNNER_TEMP/audit-e2e.log" || { echo "::error::the browser end to end did not pass"; exit 1; } + if grep -q '^FAIL' "$RUNNER_TEMP/audit-e2e.log"; then echo "::error::a check failed"; exit 1; fi + # The page's integration and the native sink are two implementations of stream-audit's + # player: the page replays the native sink corpus's page-parity case call for call and + # signs the same bundle byte for byte (extensions/native-sink.md §10). + - name: The page replays the native sink's corpus + shell: bash + run: | + set -euo pipefail + node --test wai/wai-audit/web/native-sink.test.mjs 2>&1 | tee "$RUNNER_TEMP/native-parity.log" + grep -qE '^ℹ pass [1-9][0-9]*$' "$RUNNER_TEMP/native-parity.log" && ! grep -qE '^ℹ fail [1-9]' "$RUNNER_TEMP/native-parity.log" \ + || { echo "::error::the page did not sign the native sink's bundle"; exit 1; } diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index c64f286f1..5ab9cd4ec 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -106,6 +106,10 @@ on: # e2ee_verify.py reads this extension's §13 table, and the registry's wire identifiers # test reads its sections. - 'wai/extensions/confidentiality.md' + # tests/live_messaging.rs requires this extension's §12 to list the codes the reference + # reports, live_messaging_verify.py reads that table, and the registry's wire identifiers + # test reads its sections. + - 'wai/extensions/live-messaging.md' # tests/live_ads.rs requires this extension's §10 to list the codes the reference # reports, live_ads_verify.py reads that table, and the registry's wire identifiers test # reads its sections. @@ -114,9 +118,17 @@ on: # reports, ad_presentation_verify.py reads that table, and the registry's wire identifiers # test reads its sections. - 'wai/extensions/ad-presentation.md' + # tests/key_release.rs requires this extension's §3.1, §3.2, §7.2 and §11 tables to be the + # reference's codes, key_release_verify.py reads those tables and §3.3, and the registry's + # wire identifiers test reads its sections. + - 'wai/extensions/key-release.md' # The ad-presentation step runs the browser client's reference producer against the - # corpus. + # corpus, and the stitched-presentation step runs its stitched producer. - 'joule-code/joule-code-rs/crates/joule-moq-web/web/ads/**' + # tests/stitched_presentation.rs requires this extension's §9 to list the codes the + # reference adds, stitched_presentation_verify.py reads that table, and the registry's + # wire identifiers test reads its sections. + - 'wai/extensions/stitched-presentation.md' # tests/live_provenance.rs requires this extension's §12 to list the codes and the C2PA # codes the reference reports, c2pa_live_verify.py reads that table, and the registry's # wire identifiers test reads its sections. @@ -135,6 +147,20 @@ on: # reports, upscale_verify.py reads that table, and the registry's wire identifiers test # reads its sections. - 'wai/extensions/exact-upscale.md' + # tests/prior_wire.rs requires this extension's §13 to list the codes the reference + # reports, prior_forms_verify.py reads that table, and the registry's wire identifiers + # test reads its sections. + - 'wai/extensions/prior-carriage.md' + # tests/scene_map.rs requires this extension's §8 to list the codes the reference + # reports, scene_verify.py reads that table, and the registry's wire identifiers test + # reads its sections. + - 'wai/extensions/scene-carriage.md' + # tests/avatar_face.rs requires this extension's §10 to list the codes the reference + # reports and its §7.9 to state the training report's figures, avatar_face_verify.py + # reads §10, and the registry's tests read SPEC §5's registration of wai.avatar.face. + - 'wai/extensions/avatars.md' + # The tool that trained the corpus's generator and wrote its inputs and its report. + - 'wai/tools/wai_avatar_face_train.py' # tests/ai_usage.rs requires this extension's §9 to list the codes the reference reports, # ai_usage_verify.py reads that table, and the registry's wire identifiers test reads its # sections. @@ -143,16 +169,35 @@ on: # reports, contribution_verify.py reads that table, and the registry's wire identifiers # test reads its sections. - 'wai/extensions/contribution.md' + # live_contribution_verify.py reads this extension's §7 list of the link record's + # reasons and requires the corpus to raise each one. + - 'wai/extensions/live-contribution.md' # tests/settlement.rs requires this extension's §11 to list the codes the reference # reports, settlement_verify.py reads that table, and the registry's wire identifiers test # reads its sections. - 'wai/extensions/settlement.md' + # tests/receipt_ledger.rs requires this extension's §10 to list the codes the reference + # reports, receipt_ledger_verify.py reads that table, and the registry's wire identifiers + # test reads its sections. Its corpus is generated from the metering, settlement, ads, + # presentation and stitched corpora and the packs of pack-conformance, all under the paths + # above. + - 'wai/extensions/receipt-ledger.md' + # tests/stream_audit.rs requires this extension's §14 to list the codes the reference + # reports, stream_audit_verify.py reads that table, and the registry's wire identifiers test + # reads its sections. + - 'wai/extensions/stream-audit.md' + # tests/native_sink.rs requires this extension's §8 to list exactly the reasons its + # corpus reaches, and native_sink_verify.py reads that table. + - 'wai/extensions/native-sink.md' # tests/browser_sink.rs requires this extension's §11 to list the codes the reference # reports, browser_sink_verify.py reads its §2, §4.1, §4.2 and §11 tables and # capability-constraints' §3.1 register, and the registry's wire identifiers test reads # its sections. - 'wai/extensions/browser-sink.md' - 'wai/extensions/capability-constraints.md' + # sink_energy's unit tests require this extension's §8 to list exactly the codes the + # reference reports, and sink_energy_verify.py reads that table. + - 'wai/extensions/sink-energy.md' # The browser sink's functions for a page, held to the same corpus natively. - 'wai/wai-live-web/**' # tests/takedown.rs requires this extension's §11 to list the codes the reference @@ -182,12 +227,23 @@ on: # reports and its §3.2 table to name the reference's descriptors, exploration_verify.py # reads §13, and the registry's wire identifiers test reads its sections. - 'wai/extensions/exploration.md' + # tests/platform_encode.rs requires this extension's §12 to list the reasons the reference + # reports, and encode_verify.py reads that table. + - 'wai/extensions/platform-encode.md' # tests/co_watch.rs requires this extension's §12 to list the codes the reference reports, # co_watch_verify.py reads that table, and the registry's wire identifiers test reads its # sections. - 'wai/extensions/co-watch.md' + # tests/live_world.rs requires this extension's §11 to list the codes the reference reports. + - 'wai/extensions/live-worlds.md' # The co-watch step runs the browser client's co-watch module against the corpus. - 'joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch*' + # tests/remote_desktop.rs requires this extension's §12 to list the codes the reference + # reports, remote_desktop_verify.py reads its four tables, and the registry's wire + # identifiers test reads its sections. + - 'wai/extensions/remote-desktop.md' + # The remote desktop step runs the browser viewer's module against the corpus. + - 'joule-code/joule-code-rs/crates/joule-moq-web/web/room/remote*' # tests/dred_exact.rs requires this extension's §8 to list the codes the reference # reports and its §4 to state the reference's digests, tests/dred_exact_measure.rs its §6 # table, dred_verify.py reads §4 and §8, and the registry's tests read SPEC §5 against it. @@ -221,6 +277,25 @@ on: # reports, pq_verify.py reads that table, and the registry's wire identifiers test reads # its §2. - 'wai/extensions/pq-signatures.md' + # tests/format_probe.rs requires format-probes §2.4 and §12 to list the reference's probes + # and codes, and projection.md §4 and §5 to state its codes; format_probe_verify.py reads + # format-probes' tables; and the registry's wire identifiers test reads projection.md, + # loc-carriage.md and delivery-telemetry.md (whose §6.6 cmsd_read_verify.py reads). + - 'wai/extensions/format-probes.md' + - 'wai/extensions/projection.md' + - 'wai/extensions/loc-carriage.md' + - 'wai/extensions/delivery-telemetry.md' + # The determinism-cost record, its verifier, and the two texts whose quoted figures + # the verifier checks against the record. + - 'wai/research/determinism-cost.md' + - 'wai/research/determinism-cost/**' + - 'wai/tools/wai_determinism_cost_verify.py' + - 'wai/extensions/determinism-tiers.md' + # tests/stream_termination.rs requires this extension's §7 to list the codes the reference + # reports and capability-constraints §4.2 to name the match's bits, + # stream_termination_verify.py reads both, and the registry's wire identifiers test reads + # its sections. + - 'wai/extensions/stream-termination.md' - 'wai/wai-web/**' # The audio provenance check below reads the corpora above with this tool. - 'wai/tools/wai_audio_provenance_check.py' @@ -321,7 +396,7 @@ jobs: # input) fails here rather than wrapping. - name: Library unit tests (the fixed-point determinism floors) working-directory: wai/wai-rs - run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery,staged,video_int,audio_int,splat_int,stage_measure,telemetry,video_package,http + run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims,key_discovery,staged,video_int,audio_int,splat_int,stage_measure,telemetry,video_package,http,format_probe # The JPEG AI exact decoder reproduces across platforms only if no platform math # library is on its path. Two guards, each failing the step: clippy's # disallowed-methods (wai-rs/clippy.toml), which each module on the decode path @@ -451,13 +526,17 @@ jobs: # int_video_ops.rs and wir1_ops.rs hold WIV1 and WIR1, which decode through the # operator set's stage operators with no graph (integer-operators §13.5), to their # stage forms, goldens and refusals; `i32_forms` builds E9's 32-bit form, which they - # and int_ops_differential.rs hold to the exact one. + # and int_ops_differential.rs hold to the exact one. tests/int_ops_conformance.rs + # decodes every graph vector of ops-conformance/ through the fast kernels and holds + # each to the integers it states. Each of these runs every kernel set the leg's + # processor runs (int_kernels::equivalence_policies: the wide kernels, and NEON on + # arm64, AVX2 with or without VNNI on x86_64); int-kernels-amd below runs AVX-512. - name: Fast integer kernels ≡ reference kernels (overflow checks on) working-directory: wai/wai-rs - run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_ops_conformance --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration - name: Fast integer kernels ≡ reference kernels (release, overflow checks off) working-directory: wai/wai-rs - run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test manifest_parse_memory --test manifest_reader_equivalence --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms --lib --test int_kernels_vectors --test int_ops_conformance --test int_kernels_geometry --test int_synth_ops --test int_ops_differential --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp --test int_mlicpp_preflight --test int_mlicpp_ops --test int_world_ops --test int_world_memory --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_audio --test int_splat --test generate --test cli_generate_score --test int_payload --test int_payload_preflight --test int_payload_cost --test int_set_cache --test int_video_enc_memory --test cli_stage --test staged_resources --test stage_measure --test stage_measure_bounds --test manifest_parse_memory --test manifest_reader_equivalence --test int_video --test int_video_halfpel --test int_video_keyframe --test int_world --test int_world2 --test jpegai_bitstream_calibration # The integer decode path builds under each of its features alone, so a sink can take # one medium without the others, and for wasm32 (a browser sink); `int_payload` arms a # medium only under its feature, and none needs `neural`. So does the staged-measurement @@ -469,7 +548,7 @@ jobs: working-directory: wai/wai-rs run: | set -euo pipefail - for f in neural_int video_int audio_int splat_int staged stage_measure telemetry http; do + for f in neural_int video_int audio_int splat_int staged stage_measure telemetry http format_probe; do cargo check --lib --no-default-features --features "$f" done # The decode bench prints its machine line from bench_env; no other step builds it. @@ -484,6 +563,8 @@ jobs: cargo build --lib --target wasm32-unknown-unknown --no-default-features --features receipt_claims,stage_measure # So is delivery telemetry: a browser sink writes the same reports. cargo build --lib --target wasm32-unknown-unknown --no-default-features --features telemetry + # And the header probes: a browser reads a payload's parameters the same way. + cargo build --lib --target wasm32-unknown-unknown --no-default-features --features format_probe # carriage: every registered capability round-trips byte-exact. cli_wrap_intent and # cli_registry run the `wai` binary, which builds without the codecs feature: the # intent rule of `wai wrap`, its refusal of candidate capabilities (SPEC §5), its media @@ -711,6 +792,50 @@ jobs: # above runs natively. # One leg: wasm32 is the same machine on every host. Linux, so it does not queue on the # single macOS runner. + # The fast integer kernels on the AVX-512 host (labelled linux-amd), whose processor runs + # every x86_64 kernel set (wide, AVX2, AVX2 with VNNI, AVX-512, AVX-512 with VNNI), none + # of whose AVX-512 kernels the other legs' processors run. The same tests as the kernel + # steps of cargo-classes, in both profiles; the runner declares its sets, and a set the + # tests did not find fails the job instead of passing as a skip. + int-kernels-amd: + name: fast integer kernels, every x86_64 kernel set (AVX-512 silicon) + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux-amd + timeout-minutes: 90 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_BUILD_JOBS: '12' + steps: + - name: Fetch the source + shell: bash + run: | + set -euo pipefail + want="${{ github.event.pull_request.head.sha || github.sha }}" + rm -rf src && mkdir src && cd src + git init -q + # Fetch the commit itself, not the ref. The host names its own route to the + # forge (WAI_FORGE_URL). + git fetch -q --depth 1 "${WAI_FORGE_URL:-http://100.64.224.0:3400}/Transaction-Science/open-standards.git" "$want" + git checkout -q FETCH_HEAD + got="$(git rev-parse HEAD)" + [ "$got" = "$want" ] || { echo "::error::fetched $got, not $want"; exit 1; } + - name: Fast integer kernels ≡ reference kernels, every kernel set, both profiles + shell: bash + working-directory: src/wai/wai-rs + run: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + export CARGO_TARGET_DIR="$HOME/hw-target/wai-rs-int-kernels" + # The sets this host's processor runs, declared here (or by the runner); the + # library test the_kernel_sets_found_are_the_declared_ones fails on any other. + export WAI_INT_ISAS_EXPECT="${WAI_INT_AMD_ISAS_EXPECT:-wide,avx2,avx2-vnni,avx512,avx512-vnni}" + F=neural_int,video_int,audio_int,world_int,splat_int,generate,score,staged,stage_measure,legacy_int,i32_forms + T="--test int_kernels_vectors --test int_kernels_geometry --test int_synth_ops --test int_splat_ops --test int_audio_ops --test int_hyper_ops --test int_mlicpp_ops --test int_world_ops --test jpegai_ops --test jpegai_nets_ops --test int_video_ops --test wir1_ops --test int_ops_conformance" + for p in "" --release; do + nice -n 10 cargo test --locked $p --no-default-features --features $F --lib $T + done + quantum-wasip2: needs: relevant if: needs.relevant.outputs.run == 'true' @@ -852,6 +977,65 @@ jobs: wasmtime run --dir ../quantum-conformance::/corpus \ target/wasm32-wasip2/debug/wai_quantum_vectors.wasm verify /corpus + # The face generator under wasm32-wasip2, run by wasmtime (extensions/avatars.md §7.8): + # wai_avatar_face_vectors verify, built for wasm32, regenerates every file of + # live-conformance/avatar-face from its seeds and inputs, every frame through the integer + # operator set's interpreter compiled to wasm32, and requires the committed bytes, which a + # native build wrote: the frames are the same on wasm32 as on the CPU. One leg: wasm32 is + # the same machine on every host. Linux, so it does not queue on the single macOS runner. + avatar-face-wasip2: + needs: relevant + if: needs.relevant.outputs.run == 'true' + name: faces from keypoints under wasm32-wasip2 (wasmtime) + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + + - name: Install Rust 1.98.0 with the wasm32-wasip2 target + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + rustup target add --toolchain 1.98.0 wasm32-wasip2 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # The release binary, pinned by version and SHA-256, rather than a build from source. + - name: Install wasmtime 49.0.2 + shell: bash + run: | + set -euo pipefail + [ "$(uname -m)" = x86_64 ] || { echo "::error::the pinned wasmtime archive is x86_64; this host is $(uname -m)"; exit 1; } + if ! command -v xz >/dev/null 2>&1; then + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq xz-utils + fi + v=49.0.2 + f="wasmtime-v$v-x86_64-linux.tar.xz" + curl --proto '=https' --tlsv1.2 -sSfL -o "$RUNNER_TEMP/$f" \ + "https://github.com/bytecodealliance/wasmtime/releases/download/v$v/$f" + echo "a4d6e9e3a5a60f527cf7793d674c48930c80c2e8977995b8a275cad3254b9322 $RUNNER_TEMP/$f" | sha256sum -c - + tar -xJf "$RUNNER_TEMP/$f" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/wasmtime-v$v-x86_64-linux" >> "$GITHUB_PATH" + + # One codegen unit for the wai package: see the quantum job's last step for the LLD 22 + # cdylib crash this avoids. + - name: wai_avatar_face_vectors verify under wasmtime + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + cargo build --locked --release --target wasm32-wasip2 --no-default-features --features avatar_face --bin wai_avatar_face_vectors \ + --config 'profile.release.package.wai.codegen-units=1' + t="${CARGO_TARGET_DIR:-target}" + wasmtime run --dir ../live-conformance/avatar-face::/corpus \ + "$t/wasm32-wasip2/release/wai_avatar_face_vectors.wasm" verify /corpus | tee "$RUNNER_TEMP/face-wasm.log" + [ "$(grep -c '^PASS ' "$RUNNER_TEMP/face-wasm.log")" = 3 ] || { echo "::error::not every check passed under wasm32"; exit 1; } + # The generated v2 vocabulary (wai/vocab/v2.*) is checked as a consumer uses it: # sample manifests, typed and untyped, WAI1 and WAI2, are expanded through the # JSON-LD context exactly as written on the wire and validated against the SHACL @@ -896,9 +1080,9 @@ jobs: name: independent verifiers · claims, JWP, key discovery and integer payloads (Python) runs-on: linux-arm64 env: - WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge + WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging WAI_PQ_FEATURES: synthesis,pq_signatures - WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness + WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face steps: - uses: actions/checkout@v5 @@ -964,9 +1148,13 @@ jobs: mkdir -p "$RUNNER_TEMP/wai-bins/union" "$RUNNER_TEMP/wai-bins/pq" cargo build --no-default-features --features "$WAI_FEATURES" \ --bin wai_http_vectors --bin wai_stage_measure --bin wai_browser_sink_vectors \ - --bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors + --bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors \ + --bin wai_sink_energy_vectors --bin wai_stitched_presentation \ + --bin wai_stream_termination_vectors --bin wai_format_probe for b in wai_http_vectors wai_stage_measure wai_browser_sink_vectors \ - wai_claim_vectors wai_jwp_vectors wai_signer_vectors; do + wai_claim_vectors wai_jwp_vectors wai_signer_vectors \ + wai_sink_energy_vectors wai_stitched_presentation \ + wai_stream_termination_vectors wai_format_probe; do cp "${CARGO_TARGET_DIR:-target}/debug/$b" "$RUNNER_TEMP/wai-bins/union/" done # Copied before this build replaces target/debug's copies of the three it shares. @@ -983,9 +1171,13 @@ jobs: --test browser_sink --test takedown --test influence --test direction --test co_performance \ --test age_assurance --test bearer_receipts --test exploration --test co_watch \ --test telemetry_aggregate --test exact_tonemap --test splat_exact --test live_watermark \ - --test derive_edge + --test derive_edge --test det_cost_bin --test sink_energy --test key_release \ + --test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \ + --test stream_audit --test native_sink --test platform_encode --test remote_desktop \ + --test stream_termination --test live_world --test format_probe --test live_messaging cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \ - --test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness + --test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \ + --test avatar_face # The integer payload corpus's second implementation (int-payload-conformance/README): # it parses every payload with the extension's rules and check order, converts every @@ -996,6 +1188,21 @@ jobs: working-directory: wai shell: bash run: python3 int-payload-conformance/int_payload_verify.py int-payload-conformance + # The determinism-cost record (research/determinism-cost.md): its summary recomputed + # from its readings by a second implementation that shares no code with the tool that + # wrote it (tools/wai_determinism_cost_verify.py, standard library only, run isolated), + # and every figure the research page and determinism-tiers §7 quote from it checked + # against it (each is followed by a `det` marker naming its place in the record). Then + # wai_det_cost, the decoder the record ran, held to the committed int_hyper vector and + # every WIH1 refusal fixture of int-payload-conformance/. + - name: determinism cost · the record, the text quoting it, and wai_det_cost + working-directory: wai + shell: bash + run: | + set -euo pipefail + python3 -I tools/wai_determinism_cost_verify.py research/determinism-cost/record.json \ + research/determinism-cost.md extensions/determinism-tiers.md + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test det_cost_bin) # The staged-delivery corpus's second implementation (staged-conformance/README): it # replays every case from its stage-0 buffers through its own reading of the # extension (refines, WIR1, the sink's steps, holds, batches, conflicts, deadlines, @@ -1237,6 +1444,32 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib energy_steering::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test energy_steering) python3 -I live-conformance/energy_steering_verify.py live-conformance/steering + # wai.sink.energy (extensions/sink-energy.md): the reference module's unit tests (a + # bracket summary is its intervals in integers, a round's figure and least declaration + # are energy-measurement §6.2's — checked against energy_report's reference — a round is + # refused by its first broken rule, a row seals its median and covers its spread, a + # segment's bounded window has no count term, the estimators and shares round half up, + # records sign and their identities cover the signature, §8 lists exactly the codes), the + # readers' unit tests (a counter is live only as a live rail advances, an advance crosses + # the wrap; on macOS, the per-process counter reads this process) and + # tests/sink_energy.rs (the committed corpus regenerates byte for byte from its fixed + # seeds, every case gets its stated outcome, every reason of §8 refuses a case, and every + # measured table under tables/ is accepted with its readings and procedure); then the + # corpus's second implementation, sink_energy_verify.py, written from the extension's text + # and sharing no code with wai-rs, which recomputes every round, row, segment and split in + # integers and requires every mutated copy of the corpus to fail; standard library only, + # run isolated (-I). These are the integer checks; the counters themselves are read by + # energy-hardware.yml's sink jobs, on the runners that have them. + - name: sink energy · wai-rs and sink_energy_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib sink_energy::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib sink_meter::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test sink_energy) + (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_sink_energy_vectors" verify ../live-conformance/sink-energy) + python3 -I live-conformance/sink_energy_verify.py live-conformance/sink-energy # wai.live.moderation (extensions/live-moderation.md): the reference module's unit tests # (policies, statements and notices round-trip and their rules refuse, the export's # strings and dates, the content types, the states that show an item) and @@ -1308,6 +1541,28 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib wai_video_ssai::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test live_ads) python3 -I live-conformance/live_ads_verify.py live-conformance/ads + # The proxy form of wai.video.keys (extensions/key-release.md): the reference module's unit + # tests (the commitment list, the entitlement and the ticket round-trip and their rules + # refuse, each check of the decision in its order, offline licences and renewals, each own + # rule of the receipt, its strict signature, the CPIX reader and the derivation of a list + # from a CPIX document), form 1's unit tests (its legacy pin unchanged), and + # tests/key_release.rs (every file of the committed corpus regenerates byte for byte from + # its fixed seeds and no other file is there, every log gets its stated outcome, and the + # extension's tables are the reference's codes); then the corpus's second implementation, + # key_release_verify.py, written from the extension's text and sharing no code with wai-rs, + # which reads the extension's tables, derives every list from its CPIX document with the + # standard library's XML parser, recomputes every decision of every log, verifies every + # signature with WAI's strict Ed25519 rule and BLAKE3 from claim-conformance, and requires + # every mutated copy of the corpus to fail; standard library only, run isolated (-I). + - name: key release · wai-rs and key_release_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib key_release::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib wai_video_keys::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test key_release) + python3 -I live-conformance/key_release_verify.py live-conformance/keys # wai.live.ad_presentation (extensions/ad-presentation.md): the reference module's unit # tests (claims round-trip and their rules refuse, the quartile, the qualifying run, the # touched objects, a policy's presentation fields, RFC 9577's TokenChallenge) and @@ -1330,6 +1585,33 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test ad_presentation) python3 -I live-conformance/ad_presentation_verify.py live-conformance/presentation node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs + # wai.video.stitched_presentation (extensions/stitched-presentation.md): the reference + # module's unit tests (claims and pod-input records round-trip and their rules refuse, + # markers' bodies, a CPM price scaled to one impression, the XML subset) and the + # live-compose ad frames' (a squeeze's window and L tile the canvas, a double box's + # boxes), and tests/stitched_presentation.rs (every file of the committed corpus + # regenerates byte for byte from its fixed seeds and no other file is there, wai-rs gives + # every record, claim and ad its stated facts and every vector its spend by currency, and + # §9 lists exactly the codes the reference adds); then the tool's own check of the corpus + # through its binary; then the corpus's second implementation, + # stitched_presentation_verify.py, written from the extension's text and sharing no code + # with wai-rs, which reads every playlist, asset list, MPD, VAST document, form-1 receipt, + # record and claim itself, requires every mutated copy of the corpus to fail and catches + # each of its own checks dropped; standard library only, run isolated (-I). Last, the + # browser client's stitched producer (joule-moq-web/web/ads/stitched.js), which reads the + # markers from the corpus's manifests, measures an ad's region, and writes and signs the + # corpus's claims, byte for byte. + - name: stitched presentation · wai-rs, stitched_presentation_verify.py and the reference producer + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stitched_presentation::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib live_compose::layout::frame_tests) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stitched_presentation) + (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_stitched_presentation" vectors verify ../live-conformance/stitched) + python3 -I live-conformance/stitched_presentation_verify.py live-conformance/stitched + node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/stitched.test.mjs # wai.live.provenance (extensions/live-provenance.md): the reference module's unit tests # (boxes, chunks, emsg and the BMFF hash; the JUMBF store; COSE_Sign1 and the OKP key; # certificates written and read, their profile and chain; identifiers, track renderings, @@ -1442,6 +1724,61 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib robustness::) (cd wai-rs && cargo test --release --no-default-features --features "$WAI_RELEASE_FEATURES" --test robustness) python3 -I live-conformance/robustness_verify.py live-conformance/robustness + # Tensor files as prior forms (extensions/prior-carriage.md §9 to §14): the module's unit + # tests (each layout read strictly and written canonically, the arithmetic coder's integers + # round-tripping and a damaged payload caught, the profile's checks in their order, both + # bindings through every form) and tests/prior_wire.rs (the committed corpus regenerates + # byte for byte from its pinned native artifacts; §13 lists exactly the reference's codes; + # every corpus prior in every form loads to its native bytes, and the two trained WSR1 + # priors give the same upscaled picture as their native files); then the corpus's second + # implementation, prior_forms_verify.py, written from the extension's text and sharing no + # code with wai-rs, which also requires every byte-flipped copy of an accepted file to be + # caught; standard library only, run isolated (-I). + - name: prior forms · wai-rs and prior_forms_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- prior_wire:: strict_json:: codecs::prior_form) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test prior_wire) + python3 -I live-conformance/prior_forms_verify.py live-conformance/prior-forms + # Scene carriage (extensions/scene-carriage.md): the module's unit tests (the conversions + # and spellings of §3 against fixed values, the containers an import builds equal to the + # ones wai-rs's world and audio-scene modules write, base64 and the USD subset's reader) + # and tests/scene_map.rs (the committed corpus regenerates byte for byte: every bundle + # written as glTF and USD and read back, every refusal reached; §8 lists exactly the + # reference's codes); then the corpus's second implementation, scene_verify.py, written + # from the extension's text and sharing no code with wai-rs, which also requires every + # byte-flipped copy of an exported document to be caught; standard library only, run + # isolated (-I). + - name: scene carriage · wai-rs and scene_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- scene_map:: strict_json::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test scene_map) + python3 -I live-conformance/scene_verify.py live-conformance/scene + # wai.avatar.face (extensions/avatars.md §6 to §13): the module's unit tests (the + # generator's wai-ops/1 graph against §7.6's arithmetic written out loop by loop; the warp + # at rest is the reference; the mask's table; the files read and refused in their order), + # and tests/avatar_face.rs (the committed corpus regenerates byte for byte from its fixed + # seeds and its pinned inputs, and no other file is there; wai-rs gives every form, frame + # and case what it states; §10 lists exactly the reference's codes; §7.9's figures are + # training.json's; the registered generator meets acc64 and not acc32); then the corpus's + # second implementation, avatar_face_verify.py, written from the extension's text and + # sharing no code with wai-rs, which runs the driver and the generator's arithmetic in + # Python's integers, verifies the claims, the log and the label policy with + # synthesis_verify.py, and requires every mutated copy of the corpus to fail; standard + # library only, run isolated (-I). + - name: faces from keypoints · wai-rs and avatar_face_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib avatar_face::) + (cd wai-rs && cargo test --release --no-default-features --features "$WAI_RELEASE_FEATURES" --test avatar_face) + python3 -I live-conformance/avatar_face_verify.py live-conformance/avatar-face # wai.ai.usage (extensions/ai-usage.md): the reference module's unit tests (RFC 9651's # Dictionary parser against the vocabulary's examples and the RFC's edges, statements # read and refused in their steps' order, the precedence rule, HTTP fields compared by @@ -1490,6 +1827,21 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib contribution::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test contribution) python3 -I live-conformance/contribution_verify.py live-conformance/contribution + # wai.live.contribution (extensions/live-contribution.md): the link records' corpus + # under live-conformance/links/, which joule-moq-ingest's reference generates and its + # own tests check (joule-code-conformance.yml); here its second implementation, + # live_contribution_verify.py, written from the extension's text and sharing no code + # with the reference, reads every part and series itself, checks every signature with + # WAI's strict Ed25519 rule from claim-conformance, requires each vector's stated outcome + # and reason and every reason of §7's last list, requires every mutated copy to be + # refused, and drops each of its own checks in turn and requires the corpus to catch the + # weakened verifier; standard library only, run isolated (-I). + - name: live contribution · live_contribution_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + python3 -I live-conformance/live_contribution_verify.py live-conformance/links # wai.delivery.settlement (extensions/settlement.md): the reference module's unit tests # (range proofs over every range around every chunk boundary of objects of many lengths # against BLAKE3's own root, tampered proofs refused, the group tree of jwp-receipts §2.2, @@ -1513,6 +1865,71 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib settlement::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test settlement) python3 -I live-conformance/settlement_verify.py live-conformance/settlement + # wai.delivery.ledger (extensions/receipt-ledger.md): the reference module's unit tests and + # tests/receipt_ledger.rs (vectors.json regenerates byte for byte from its fixed seeds and + # from the sources it takes from the metering, settlement, ads, presentation and stitched + # corpora and the packs of pack-conformance, and no other file is there; wai-rs gives every vector its + # stated outcome; every reason of §10 and every reused code refuses a vector, and so does + # each source kind's own extension; §10 lists exactly the reference's codes); then the + # corpus's second implementation, receipt_ledger_verify.py, written from the extension's + # text and sharing no code with wai-rs, which verifies each source with that corpus's own + # second implementation (live_metering_verify.py, settlement_verify.py, live_ads_verify.py + # with ad_presentation_verify.py, stitched_presentation_verify.py, pack_verify.py) and + # reports a stitched stream's statement as that verifier gives it, checks signatures with + # WAI's strict Ed25519 rule, recomputes every line, citation, energy row and report, + # requires every mutated copy of the corpus to fail, and drops each of its own checks in turn and requires + # the corpus to catch the weakened verifier; standard library only, run isolated (-I). + - name: receipt ledger · wai-rs and receipt_ledger_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib receipt_ledger::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test receipt_ledger) + python3 -I live-conformance/receipt_ledger_verify.py live-conformance/ledger + # wai.delivery.audit (extensions/stream-audit.md): the reference module's unit tests (the + # acknowledgement windows, chains and repeats of §4.2, the origin rule, the report's bytes, + # channel names) and tests/stream_audit.rs (vectors.json regenerates byte for byte from its + # fixed seeds and no other file is there, wai-rs gives every session and report its stated + # outcome, §14 lists exactly the reference's codes, each of which the corpus gives, beside + # every code it borrows); then the corpus's second implementation, stream_audit_verify.py, + # written from the extension's text and sharing no code with wai-rs, which reads + # acknowledgements with settlement_verify.py, claims with ad_presentation_verify.py and logs + # and energy classes with live_metering_verify.py, checks every signature with WAI's strict + # Ed25519 rule, recomputes every report, requires every mutated copy of the corpus to fail, + # and drops each of its own checks in turn and requires the corpus to catch the weakened + # verifier; standard library only, run isolated (-I). The collector and the player + # integration run in wai-audit.yml. + - name: stream audit · wai-rs and stream_audit_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stream_audit::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stream_audit) + python3 -I live-conformance/stream_audit_verify.py live-conformance/audit + # The native sink (extensions/native-sink.md): the reference module's unit tests (dates, + # durations and attribute lists read as a player reads them, origins serialized as RFC 6454 + # does, names that state a content hash and names that do not, the acknowledgements + # rebuilt from hashes alone equal to stream-audit's reference, a bundle that verifies and + # one refused for another owner or channel) and tests/native_sink.rs (vectors.json + # regenerates byte for byte from its seeds and no other file is there, every call gets the + # answer it states, §8 lists exactly the reasons the corpus reaches, and every case run + # through the C interface's symbols gets the same answers); then the corpus's second + # implementation, native_sink_verify.py, written from the extension's text and sharing no + # code with wai-rs, which replays every case, reads playlist receipts and their chain with + # http_verify.py, holds every bundle to its own session field by field and verifies it with + # stream_audit_verify.py, requires every mutated copy of the corpus to fail, and drops each + # of its own checks in turn and requires the corpus to catch the weakened verifier; + # standard library only, run isolated (-I). The Swift package runs in wai-sink-swift.yml. + - name: native sink · wai-rs and native_sink_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib native_sink::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test native_sink) + python3 -I live-conformance/native_sink_verify.py live-conformance/native-sink # The browser sink (extensions/browser-sink.md): the reference module's unit tests (the # registry gate's rows, registered tiers, integers as §5 reads them, a binding's # canonical form, an answer for an unregistered codec string never read, powerEfficient @@ -1699,6 +2116,27 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib exploration::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test exploration) python3 -I live-conformance/exploration_verify.py live-conformance/exploration + # wai.video.encode (extensions/platform-encode.md): the reference module's unit tests (the + # Exp-Golomb reader, emulation prevention, NAL splitting, length-prefixed samples, the + # resampler, the source reader, the strict JSON scan and the date-time) and + # tests/platform_encode.rs (the fixture streams are the pinned bytes, every generated file + # regenerates byte for byte, every case gets its outcome, every record reason has a case, and + # §12 lists exactly the reference's reasons); then the corpus's second implementation, + # encode_verify.py, written from the extension's text and sharing no code with wai-rs or + # wai-pack, which reads every Annex B stream, its access units and parameter sets itself, + # verifies every record with WAI's strict Ed25519 rule and every object with BLAKE3 from + # claim-conformance, checks the encoded renditions against the base packaging golden, + # requires every mutated copy of the corpus to fail, and drops each of its own checks in turn + # and requires the corpus or its mutations to catch the weakened verifier; standard library + # only, run isolated (-I). The encoders themselves run on the macOS leg (codec-wrappers). + - name: platform-encode · wai-rs and encode_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --locked --no-default-features --features "$WAI_FEATURES" --lib platform_encode::) + (cd wai-rs && cargo test --locked --no-default-features --features "$WAI_FEATURES" --test platform_encode) + python3 -I encode-conformance/encode_verify.py encode-conformance # wai.live.cowatch (extensions/co-watch.md): the reference module's unit tests (the policy # and its rules, statements read back and refused, the controlled position in the order of # §5.4, each branch of the catch-up rule, percentiles by nearest rank, the control attribute) @@ -1723,6 +2161,35 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test co_watch) python3 -I live-conformance/co_watch_verify.py live-conformance/cowatch node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.test.mjs + # wai.live.remote (extensions/remote-desktop.md): the reference module's unit tests (every + # statement read back and refused when a byte is short or over, the screen statement's + # field rules, the host's rules of §5.3 in their order, the grant attributes, a latency's + # legs, sealed actions: a seal with no plaintext in it that opens under the host's key + # alone, the commitment and what it binds, a seal that does not fit its actions, rules 8 + # and 9 after the others) and the crate's HPKE against RFC 9180 A.1.1; tests/remote_desktop.rs + # (vectors.json regenerates byte for byte from its fixed seeds and no other file is there, + # wai-rs gives every room its stated outcome at the commitment level and at the opened + # level, and §12 lists exactly the reference's codes, host refusals, floor refusals and + # the reasons a seal is not opened, each of which the corpus gives); + # then the corpus's second implementation, remote_desktop_verify.py, written from the + # extension's text and sharing no code with wai-rs, which verifies each room with + # live_rooms_verify.py, every signature with WAI's strict Ed25519 rule and every seal with + # the aggregation verifier's HPKE, recomputes every outcome from the rules, the floor and + # every latency's legs at both levels, requires every mutated copy of the corpus to fail and + # drops each of its own checks in turn and requires the corpus to catch the weakened + # verifier; standard library only, run isolated (-I). Last, the browser viewer's module + # (joule-moq-web/web/room/remote.js), which writes the corpus's input statements, floor + # statements and latency records byte for byte, seals every sealed input that opens again + # byte for byte, and gives every input the outcome the corpus states. + - name: remote desktop · wai-rs, remote_desktop_verify.py and the page's module + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib -- remote_desktop:: hpke::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test remote_desktop) + python3 -I live-conformance/remote_desktop_verify.py live-conformance/remote + node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/room/remote.test.mjs # wai.telemetry.aggregate (extensions/telemetry-aggregation.md): the reference module's unit # tests (the fields against a wide-arithmetic-free product, the generators' orders, # TurboSHAKE128 against RFC 9861, HPKE against RFC 9180 A.1.1, the FLP accepting a valid @@ -1795,6 +2262,46 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib splat_exact::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test splat_exact) python3 -I live-conformance/splat_exact_verify.py live-conformance/splat + # wai.live.termination (extensions/stream-termination.md) and the display match of + # capability-constraints §4: the reference module's unit tests (sides without orientation, + # each exceedance alone, the least rendition alone where none is within, a writer that + # refuses after its termination) and tests/stream_termination.rs (the committed corpus + # regenerates byte for byte from its fixed seeds, wai-rs gives every match and log its + # stated outcome and facts, every reason refuses a log, §7 lists exactly the reference's + # codes and capability-constraints §4.2 its match bits); wai-live-web's functions for a + # page held to the same corpus natively, a page's log written through them verified; then + # the corpus's second implementation, stream_termination_verify.py, written from the two + # extensions' text and sharing no code with wai-rs, which recomputes every match and every + # log's outcome and facts and requires every mutated copy of the corpus to fail; standard + # library only, run isolated (-I). The browser's own run is joule-moq-web.yml's scenario + # termination. + - name: stream termination · wai-rs, wai-live-web and stream_termination_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib stream_termination::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test stream_termination) + (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_stream_termination_vectors" verify ../live-conformance/termination) + (cd wai-live-web && cargo test --locked --features int_video --lib termination_tests) + python3 -I live-conformance/stream_termination_verify.py live-conformance/termination + # wai.live.world (extensions/live-worlds.md): the reference module's unit tests (the + # statement and its rules, the grant attribute) and tests/live_world.rs (vectors.json + # regenerates byte for byte from its fixed seeds and no other file is there, wai-rs gives + # every world its stated outcome, and §11 lists exactly the reference's codes, each of which + # a verifier raises the corpus gives); then the corpus's second implementation, + # live_worlds_verify.py, written from the extension's text and the sections it cites and + # sharing no code with wai-rs: the statement, each grant's signature and attributes, each + # receipt in its binary form under the owner's chain, each op's signature, owner, kind, + # Lamport rule and equivocation, and the session hash. Standard library only, run isolated. + - name: live worlds · wai-rs and live_worlds_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib live_world::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test live_world) + python3 -I live-conformance/live_worlds_verify.py live-conformance/worlds # wai.live.e2ee (extensions/confidentiality.md): the corpus's second implementation, # e2ee_verify.py, written from the extension's text and the documents it reads and # sharing no code with the reference (joule-moq's secure_objects and joule-moq-mls, @@ -1809,6 +2316,24 @@ jobs: working-directory: wai shell: bash run: python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee + # wai.live.message and wai.live.call (extensions/live-messaging.md): tests/live_messaging.rs + # (vectors.json regenerates byte for byte from its fixed seeds and no other file is there, + # wai-rs gives every conversation, history request, call and record its stated outcome, and + # §12 lists exactly the reference's codes, each of which the corpus gives); then the corpus's + # second implementation, live_messaging_verify.py, written from the extension's text and + # sharing no code with wai-rs, which reads descriptors with live_rooms_verify.py and Key IDs, + # keys, AES-GCM and plaintexts with e2ee_verify.py, implements the receipts' binary form and + # every rule of the extension itself, verifies every signature with WAI's strict Ed25519 + # rule, requires every mutated copy of the corpus to fail, and drops each of its own checks + # in turn and requires the corpus to catch the weakened verifier; standard library only, run + # isolated (-I). + - name: live messaging · wai-rs and live_messaging_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test live_messaging) + python3 -I live-conformance/live_messaging_verify.py live-conformance/messaging # wai.live.watermark (extensions/forensic-watermark.md): the reference module's unit tests # (the CBOR reader's deterministic-encoding rules, tokens of both COSE structures and # every mode read and their rules refuse, a record's rules, big-endian bit addressing, @@ -1853,6 +2378,25 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib png_still::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test derive_edge) python3 -I edge-conformance/edge_verify.py edge-conformance + # wai.live.injection (extensions/cue-injection.md) and Enhanced RTMP ingest + # (extensions/live-ingest.md §12): both corpora are generated through joule-moq-ingest, + # whose tests/conformance_corpus.rs regenerates them byte for byte + # (joule-code-conformance.yml). Here their second implementations, written from the + # texts and sharing no code with the reference: injection_verify.py translates every + # SCTE 104 message to its SCTE 35 sections and cues (the standard's sample among them, + # byte for byte), requires every refusal's result code and reason, runs the injector's + # rules over every scenario, verifies every chain with WAI's strict Ed25519 and derives + # its breaks, and refuses every negative chain at its event; ertmp_verify.py reads every + # RTMP message of every session and requires every object of every room track and what + # was not carried. Each then requires every mutated copy of its corpus to fail + # (--self-test). Standard library only, run isolated (-I). + - name: cue injection and Enhanced RTMP · injection_verify.py and ertmp_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + python3 -I live-conformance/injection_verify.py --self-test live-conformance/injection + python3 -I live-conformance/ertmp_verify.py --self-test live-conformance/ertmp # The quantum corpus's second implementation (quantum-sim extension), integers only: # it derives the circuit/1 phase table by its recurrence and the circuit2 table by an # interval-certified procedure, and requires both literal tables and their digests, @@ -1920,6 +2464,45 @@ jobs: echo "the $build build: every differential agrees" echo "::endgroup::" done + # Header probes (extensions/format-probes.md, projection.md): the reference module's unit + # tests (bits, Exp-Golomb and emulation prevention, the codes) and tests/format_probe.rs (the + # committed corpus regenerates byte for byte from its external vectors and holds no other + # generated file, every vector, binding and fact gets its stated outcome, and the + # extensions' tables list exactly the reference's probes and codes); then the five LCEVC + # streams fetch.sh fetches and checks by SHA-256, each of which wai_format_probe and the + # second implementation require (a missing one is a failure); then + # format_probe_verify.py, written from the two texts and sharing no code with wai-rs, + # which probes every vector itself and requires every mutated copy of the corpus to fail. + # Standard library only, run isolated (-I). + - name: header probes · wai-rs, the fetched LCEVC streams and format_probe_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib format_probe::) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test format_probe) + fetched=$(mktemp -d) + bash format-probe-conformance/fetch.sh "$fetched" + (cd wai-rs && "$RUNNER_TEMP/wai-bins/union/wai_format_probe" verify ../format-probe-conformance --fetched "$fetched") + python3 -I format-probe-conformance/format_probe_verify.py format-probe-conformance --fetched "$fetched" + # CMSD reading (extensions/delivery-telemetry.md §6.6): the second implementation, written + # from that text and RFC 9651 with its own field parser, reads every field of + # telemetry-conformance/cmsd-read/ and every field expected/cmsd.json states, and requires + # every mutated copy to fail. wai-rs's reading is checked by wai_telemetry verify above. + - name: cmsd_read_verify.py + working-directory: wai + shell: bash + run: python3 -I telemetry-conformance/cmsd_read_verify.py telemetry-conformance + # LOC carriage (extensions/loc-carriage.md): the second implementation of joule-moq's loc + # module (whose tests/loc_vectors.rs runs in joule-code-conformance), written from the text, + # with its own MOQT -22 property decoder and BLAKE3 (claim-conformance/_blake3.py): it reads + # every object back to its envelope, writes it again byte for byte, gives every refused + # object and envelope its code, and requires every mutated copy, and the corpus under a + # copy of itself with a check dropped, to fail. + - name: loc_verify.py + working-directory: wai + shell: bash + run: python3 -I live-conformance/loc_verify.py live-conformance/loc # Each feature list a step of the independent verifiers built alone, before those steps # shared one build of the union, still compiles alone: a feature that compiles only beside @@ -1957,7 +2540,9 @@ jobs: exact_upscale robustness ai_usage contribution settlement browser_sink browser_sink,receipt_claims \ takedown influence direction co_performance age_assurance bearer_receipts exploration co_watch \ telemetry_aggregate exact_tonemap splat_exact live_watermark derive_edge pq_signatures \ - synthesis,pq_signatures; do + synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \ + prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit native_sink \ + platform_encode remote_desktop stream_termination live_world format_probe live_messaging; do echo "::group::$f" cargo check --no-default-features --features "$f" --tests echo "::endgroup::" @@ -2074,6 +2659,23 @@ jobs: exit 1 fi + # The platform's media framework encodes each codec through its hardware engine, the stream + # holds to platform-encode §4.3, its signed record verifies with the stream and the source + # held, and the platform's decoder gives one picture per frame. On this runner the test + # must run and pass: a host without the engine fails it rather than skipping it, so the + # step requires all five tests of the binary, the encode among them, to have run. + - name: platform-encode · the media framework's hardware encoder, end to end + working-directory: wai/wai-rs + shell: bash + run: | + set -o pipefail + cargo test --locked --no-default-features --features platform_encode --test platform_encode \ + 2>&1 | tee "$RUNNER_TEMP/platform-encode.log" + grep -q '^test the_platform_encoder_makes_a_stream_that_holds_and_decodes \.\.\. ok$' "$RUNNER_TEMP/platform-encode.log" \ + || { echo "::error::the platform encoder test did not run and pass"; exit 1; } + grep -qE '^test result: ok\. 5 passed' "$RUNNER_TEMP/platform-encode.log" \ + || { echo "::error::expected the five tests of tests/platform_encode.rs to pass"; exit 1; } + # wai_prov needs `codecs`, so no other job builds it. Its --carry path writes the # provenance asset and receipt that wai-web's reach vectors commit: carried onto # its own receipt, a run must write both again byte for byte. A receipt changed diff --git a/.github/workflows/wai-key-proxy.yml b/.github/workflows/wai-key-proxy.yml new file mode 100644 index 000000000..5e627d37e --- /dev/null +++ b/.github/workflows/wai-key-proxy.yml @@ -0,0 +1,88 @@ +name: wai-key-proxy + +# The signing proxy and key server of extensions/key-release.md (wai/wai-key-proxy): its +# tests, which run the proxy, the key server and a fixture licence server on loopback, clippy +# with warnings as errors, and the release binary run end to end by smoke.sh. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-key-proxy/**' + - 'wai/wai-rs/src/key_release.rs' + - 'wai/wai-rs/src/key_release/**' + - 'wai/wai-rs/src/wai_video_keys.rs' + - 'wai/wai-rs/Cargo.toml' + - 'wai/live-conformance/keys/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-key-proxy.yml' + pull_request: + paths: + - 'wai/wai-key-proxy/**' + - 'wai/wai-rs/src/key_release.rs' + - 'wai/wai-rs/src/key_release/**' + - 'wai/wai-rs/src/wai_video_keys.rs' + - 'wai/wai-rs/Cargo.toml' + - 'wai/live-conformance/keys/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-key-proxy.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-key-proxy.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-key-proxy: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + proxy-linux: + name: proxy · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy + shell: bash + working-directory: wai/wai-key-proxy + run: | + set -euo pipefail + cargo test --locked 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. 2 passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::the end-to-end tests did not run"; exit 1; } + cargo clippy --locked --all-targets -- -D warnings + # smoke.sh runs a fixture licence server and picks free ports with Python. + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: The release binary end to end + shell: bash + working-directory: wai/wai-key-proxy + run: | + set -euo pipefail + cargo build --locked --release + bash smoke.sh diff --git a/.github/workflows/wai-ledger.yml b/.github/workflows/wai-ledger.yml new file mode 100644 index 000000000..e05ff5586 --- /dev/null +++ b/.github/workflows/wai-ledger.yml @@ -0,0 +1,137 @@ +name: wai-ledger service + +# The WAI receipt ledger as a service (wai/wai-ledger, extensions/receipt-ledger.md §9): its +# tests (the corpus's invoices issued byte for byte, the chain recovered from the directory +# after a restart, a source billed once, one process per directory), clippy with warnings as +# errors, and its smoke checks over HTTP against an instance started here with the corpus's +# seller key, the invoice's verification document checked by receipt_ledger_verify.py; on +# Linux and on the macOS runner (the host the service would be deployed to). + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-rs/**' + - 'wai/wai-ledger/**' + - 'wai/live-conformance/**' + - 'wai/pack-conformance/**' + - 'wai/claim-conformance/**' + - 'wai/extensions/receipt-ledger.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-ledger.yml' + pull_request: + paths: + - 'wai/wai-rs/**' + - 'wai/wai-ledger/**' + - 'wai/live-conformance/**' + - 'wai/pack-conformance/**' + - 'wai/claim-conformance/**' + - 'wai/extensions/receipt-ledger.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-ledger.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-ledger.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-ledger: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + service-linux: + name: service · linux + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy + shell: bash + working-directory: wai/wai-ledger + run: | + set -euo pipefail + cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + cargo clippy --locked --release --all-targets -- -D warnings + # smoke.sh ends with the independent verifier, receipt_ledger_verify.py, which reads + # packages with pack_verify.py and so needs the standard library's tomllib (Python 3.11): + # the runner image's own Python is older. + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: The smoke checks over HTTP + shell: bash + working-directory: wai/wai-ledger + run: | + set -euo pipefail + python3 -c 'import sys, tomllib; print(sys.version.split()[0])' \ + || { echo "::error::python3 here has no tomllib (Python 3.11 or later), which the independent verifier needs"; exit 1; } + cargo build --locked --release + dir="$RUNNER_TEMP/ledger"; key="$RUNNER_TEMP/ledger.key"; rm -rf "$dir" "$key" + # The corpus's seller key, from its fixed seed (a test key, never a deployment's). + python3 -I -c 'import hashlib,sys; sys.stdout.buffer.write(hashlib.sha256(b"wai:ledger-corpus\x01seller").digest())' > "$key" + WAI_LEDGER_KEY="$key" WAI_LEDGER_DIR="$dir" WAI_LEDGER_ADDR=127.0.0.1:18251 \ + ./target/release/wai-ledger serve 2> "$RUNNER_TEMP/service.log" & + pid=$! + trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT + for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18251/v1/health && break; sleep 0.2; done + bash smoke.sh http://127.0.0.1:18251 + + service-macos: + name: service · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests + shell: bash + working-directory: wai/wai-ledger + run: | + set -euo pipefail + nice -n 10 cargo test --locked --release 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + - name: The smoke checks over HTTP + shell: bash + working-directory: wai/wai-ledger + run: | + set -euo pipefail + nice -n 10 cargo build --locked --release + dir="$RUNNER_TEMP/ledger"; key="$RUNNER_TEMP/ledger.key"; rm -rf "$dir" "$key" + python3 -I -c 'import hashlib,sys; sys.stdout.buffer.write(hashlib.sha256(b"wai:ledger-corpus\x01seller").digest())' > "$key" + WAI_LEDGER_KEY="$key" WAI_LEDGER_DIR="$dir" WAI_LEDGER_ADDR=127.0.0.1:18251 \ + ./target/release/wai-ledger serve 2> "$RUNNER_TEMP/service.log" & + pid=$! + trap 'kill $pid 2>/dev/null || true; cat "$RUNNER_TEMP/service.log"' EXIT + for _ in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:18251/v1/health && break; sleep 0.2; done + bash smoke.sh http://127.0.0.1:18251 diff --git a/.github/workflows/wai-ops-gpu.yml b/.github/workflows/wai-ops-gpu.yml index bb7c28329..c74252db1 100644 --- a/.github/workflows/wai-ops-gpu.yml +++ b/.github/workflows/wai-ops-gpu.yml @@ -13,7 +13,8 @@ name: wai-ops-gpu integer operators on GPUs # at both widths, against the committed results and the interpreter; the shader # mutants, every one of which the corpus must catch; every committed vector and # refusal fixture of every capability that runs a wai-ops/1 graph, the jpegai/v1 -# inputs included, against the decode on the CPU; and the corpus again in a +# inputs included, against the decode on the CPU; the exact-upscale derivations and +# the face generator's frames, against the CPU's; and the corpus again in a # headless browser's WebGPU, the kernels compiled by the browser's own WGSL # compiler, with the semantics suite there and its vectors held to the reference # compiled to wasm32, in the page and under Node. WAI_OPS_GPU_EXPECT declares the one adapter the host must offer, and @@ -65,6 +66,11 @@ on: - 'wai/wai-rs/src/splat_exact/**' - 'wai/wai-rs/tests/splat_exact_gpu.rs' - 'wai/live-conformance/splat/**' + # The face generator's graph on the device, over the corpus's frames. + - 'wai/wai-rs/src/avatar_face.rs' + - 'wai/wai-rs/src/avatar_face/**' + - 'wai/wai-rs/tests/avatar_face_gpu.rs' + - 'wai/live-conformance/avatar-face/**' - 'wai/wai-rs/tests/vectors/**' - 'wai/int-payload-conformance/**' - 'wai/neural-conformance/**' @@ -154,6 +160,7 @@ jobs: cargo test --locked --no-default-features --features ops_gpu,video_int,audio_int,splat_int,world_int --test ops_gpu --no-run cargo test --locked --no-default-features --features ops_gpu,exact_upscale --test exact_upscale_gpu --no-run cargo test --locked --no-default-features --features splat_exact,ops_gpu --test splat_exact_gpu --no-run + cargo test --locked --no-default-features --features ops_gpu,avatar_face --test avatar_face_gpu --no-run - name: wai-rs builds the DRED profile's GPU test target working-directory: wai/wai-rs run: cargo test --locked --no-default-features --features dred_exact,ops_gpu --test dred_exact_gpu --no-run @@ -274,6 +281,23 @@ jobs: grep -q '^test result: ok\. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out' "$log" || { echo "::error::the splat GPU test did not run and pass"; exit 1; } grep -qE '^splat-exact gpu: key=gpu-metal-0 views=[1-9][0-9]* differ=0 mutants_survived=0 ' "$rep" \ || { echo "::error::no equal splat run on gpu-metal-0, or a mutant survived"; exit 1; } + # The face generator (extensions/avatars.md §7.8): every frame the avatar-face corpus + # names, from the seeded and the registered generator, is the CPU's frame and the corpus's + # digest with the generator's graph on the device, every graph run on the device and + # compared with the interpreter. + - name: The face generator's frames with its graph on the GPU + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + log="$RUNNER_TEMP/avatar-face-gpu.log" + rep="$RUNNER_TEMP/avatar-face-gpu.report" + : > "$rep" + WAI_OPS_GPU_REPORT="$rep" nice -n 10 cargo test --locked --release --no-default-features --features ops_gpu,avatar_face \ + --test avatar_face_gpu -- --nocapture 2>&1 | tee "$log" + grep -q '^test result: ok\. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out' "$log" || { echo "::error::the test did not run and pass"; exit 1; } + grep -qE "^face-gpu: key=gpu-metal-0 frames=[1-9][0-9]* same=true graphs_on_gpu=[1-9][0-9]* .* on_cpu=0 .* differed=0 errors=\[\] " "$rep" \ + || { echo "::error::no equal run on the GPU"; exit 1; } - name: wasm-bindgen CLI 0.2.127, and the wasm32-unknown-unknown target shell: bash run: | diff --git a/.github/workflows/wai-ops-npu.yml b/.github/workflows/wai-ops-npu.yml new file mode 100644 index 000000000..1185b69fa --- /dev/null +++ b/.github/workflows/wai-ops-npu.yml @@ -0,0 +1,182 @@ +name: wai-ops-npu integer operators on neural processors + +# wai-ops/1-npu on neural processors (wai/wai-ops-npu): the integer operator set's +# accelerator subset (extensions/integer-operators.md §9.2, §9.5) with its arithmetic +# on one compute core of a tile array. The crate builds the core's program with the +# array's open toolchain (Apache-2.0 with the LLVM exception, pinned below) and its +# host side with the system's C++ compiler against the device runtime, both at run +# time, into a cache keyed by their sources and the toolchain. +# +# - npu-hw (`linux-amd`, framework-128gb, its neural processor): the probes (the +# unit's rounding modes at every shift, its saturation, its accumulator lanes, +# and the kernel's requantisation on 786,432 cases); every graph vector of +# wai/ops-conformance whose graph is in the subset, against the committed results +# and the interpreter; the twelve kernel edits, each its own program, every one of +# which the corpus must catch; and the graphs the certificate keeps out of the +# subset by acc32 alone, which must not all agree. WAI_OPS_NPU_EXPECT declares the +# one device the host must have, and WAI_OPS_NPU_REQUIRE makes an absent device a +# failure, never a skip; the step then requires the report lines with their +# counts, so a filtered or empty run fails too. +# - checks (`linux-arm64`, no such device): the crate and its tests build, its unit +# tests pass, the device tests print SKIPPED and pass, and the same tests under +# WAI_OPS_NPU_REQUIRE=1 fail: an absent device is not a pass. +# +# Time and energy (`ops-npu-bench`) are measured by hand and recorded in +# integer-operators §9.5; this workflow does not run the bench. + +# One run per branch at a time; a push supersedes the run before it. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - 'wai/wai-ops-npu/**' + - 'wai/wai-ops/**' + - 'wai/ops-conformance/**' + - 'rust-toolchain.toml' + - '.github/workflows/wai-ops-npu.yml' + schedule: + - cron: '41 7 * * *' + +env: + # The tile array's toolchain: the compiler and its core back end, the build the + # compiler's release pins (its utils/peano-requirements.txt). The same environment + # as the wai-quantum npu-cores member's, at the same path. + TOOLCHAIN_VERSION: 1.4.3 + CORE_COMPILER: llvm-aie==22.0.0.2026090701+3e93bf7b + +jobs: + # Does this pull request's own diff touch the paths below? This forge matches a + # workflow's `paths:` against the push, so a merge of main into a branch fires the + # workflow whatever the pull request changes; every job here waits on this answer. + # A push to main, a schedule, or anything the script cannot determine answers + # `true` (scripts/pr-touches-paths.py). + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-ops-npu.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-ops-npu: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + npu-hw: + name: neural processor (framework-128gb silicon) + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux-amd + timeout-minutes: 90 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_BUILD_JOBS: '12' + steps: + - name: Fetch the source + shell: bash + run: | + set -euo pipefail + want="${{ github.event.pull_request.head.sha || github.sha }}" + rm -rf src && mkdir src && cd src + git init -q + # Fetch the commit itself, not the ref. The host names its own route to the + # forge (WAI_FORGE_URL). + git fetch -q --depth 1 "${WAI_FORGE_URL:-http://100.64.224.0:3400}/Transaction-Science/open-standards.git" "$want" + git checkout -q FETCH_HEAD + got="$(git rev-parse HEAD)" + [ "$got" = "$want" ] || { echo "::error::fetched $got, not $want"; exit 1; } + - name: The toolchain (cached) + shell: bash + run: | + set -euo pipefail + venv="$HOME/aie-$TOOLCHAIN_VERSION" + if ! "$venv/bin/python" -I -c 'import aie.iron' 2>/dev/null; then + rm -rf "$venv" + python3 -m venv "$venv" + tag="$(python3 -c 'import sys; print(f"cp{sys.version_info.major}{sys.version_info.minor}")')" + "$venv/bin/pip" install -q "https://github.com/Xilinx/mlir-aie/releases/download/v$TOOLCHAIN_VERSION/mlir_aie-$TOOLCHAIN_VERSION-$tag-$tag-manylinux_2_35_x86_64.whl" # allow-name: the toolchain's release URL + "$venv/bin/pip" install -q -f https://github.com/Xilinx/llvm-aie/releases/expanded_assets/nightly "$CORE_COMPILER" # allow-name: the core compiler's release index + "$venv/bin/pip" install -q "numpy>=2.5.2,<3.0" rich ml_dtypes cloudpickle aiofiles + "$venv/bin/pip" install -q -f https://llvm.github.io/eudsl "eudsl-python-extras==0.1.0.20260801.905+68a0d7a" --config-settings=EUDSL_PYTHON_EXTRAS_HOST_PACKAGE_PREFIX=aie + fi + echo "WAI_OPS_NPU_PYTHON=$venv/bin/python" >> "$GITHUB_ENV" + - name: The probes, the corpus, the kernel edits and the width, on the device + shell: bash + working-directory: src/wai/wai-ops-npu + run: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + export WAI_OPS_NPU_CACHE="$HOME/ops-npu-cache" + # The device this host has, declared here (or by the runner). + k="${WAI_OPS_NPU_HOST_EXPECT:-npu-1022-17f0}" + log="$RUNNER_TEMP/ops-npu.log" + export WAI_OPS_NPU_REPORT="$RUNNER_TEMP/ops-npu.report" + : > "$WAI_OPS_NPU_REPORT" + WAI_OPS_NPU_EXPECT="$k" WAI_OPS_NPU_REQUIRE=1 CARGO_TARGET_DIR="$HOME/hw-target/wai-ops-npu" \ + nice -n 10 cargo test --locked --release -- --nocapture --test-threads=1 2>&1 | tee "$log" + # Fourteen tests in five binaries: the library's eight, corpus's one, probe's + # three, mutants' one and width's one, none filtered, ignored or failed. + ran=$(awk '/^test result: ok\. [0-9]+ passed; 0 failed; 0 ignored; 0 measured; 0 filtered out/ { n += $4 } END { print n + 0 }' "$log") + [ "$ran" = 14 ] || { echo "::error::$ran tests ran and passed, not 14"; exit 1; } + ! grep -q '^test result: FAILED' "$log" || { echo "::error::a test failed"; exit 1; } + rep="$WAI_OPS_NPU_REPORT" + grep -qxF "ops-npu devices: as declared ($k)" "$rep" || { echo "::error::the device found is not $k"; exit 1; } + grep -qE "^ops-npu requantisation: key=$k [0-9]+ cases over shifts 0\.\.63 .*, 0 differ$" "$rep" || { echo "::error::the requantisation probe did not agree"; exit 1; } + grep -qE "^ops-npu corpus: key=$k .* vectors=[1-9][0-9]* decodes=[1-9][0-9]* .* declined=0 mismatches=0 errors=0 " "$rep" \ + || { echo "::error::no equal corpus run on $k"; exit 1; } + grep -qxF "ops-npu mutants: key=$k killed 12 of 12" "$rep" || { echo "::error::a kernel edit survived on $k, or the mutant test did not run"; exit 1; } + grep -qE "^ops-npu width: key=$k .* on the device [1-9][0-9]* differ " "$rep" || { echo "::error::the width test did not run"; exit 1; } + cat "$rep" + + checks: + name: builds · no device is not a pass (linux-arm64) + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux-arm64 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # This host has no such device. The device tests must say so and pass; under + # WAI_OPS_NPU_REQUIRE=1 the same tests must fail. + - name: The crate's tests, and an absent device under WAI_OPS_NPU_REQUIRE + shell: bash + working-directory: wai/wai-ops-npu + env: + WAI_OPS_NPU_EXPECT: none + run: | + set -euo pipefail + log="$RUNNER_TEMP/ops-npu.log" + rep="$RUNNER_TEMP/ops-npu.report" + : > "$rep" + WAI_OPS_NPU_REPORT="$rep" cargo test --locked --release -- --nocapture --test-threads=1 2>&1 | tee "$log" + ran=$(awk '/^test result: ok\. [0-9]+ passed; 0 failed; 0 ignored; 0 measured; 0 filtered out/ { n += $4 } END { print n + 0 }' "$log") + [ "$ran" = 14 ] || { echo "::error::$ran tests ran and passed, not 14"; exit 1; } + grep -qxF 'ops-npu devices: as declared (none)' "$rep" || { echo "::error::the absent case was not established"; exit 1; } + for t in corpus mutants width 'requantisation probe' 'rounding probe' 'lane probe'; do + grep -qF "ops-npu $t: SKIPPED" "$rep" || { echo "::error::the $t did not say SKIPPED"; exit 1; } + done + grep -qE '^ops-npu subset: vectors=[1-9][0-9]* .* in_subset=[1-9][0-9]* ' "$rep" || { echo "::error::the corpus was not read"; exit 1; } + if WAI_OPS_NPU_REQUIRE=1 cargo test --locked --release --test corpus --test probe --test mutants --test width > "$RUNNER_TEMP/require.log" 2>&1; then + echo "::error::with no device, the device tests passed under WAI_OPS_NPU_REQUIRE=1"; exit 1 + fi + grep -q 'WAI_OPS_NPU_REQUIRE is set' "$RUNNER_TEMP/require.log" \ + || { echo "::error::the tests failed under WAI_OPS_NPU_REQUIRE=1, but not for the absent device"; cat "$RUNNER_TEMP/require.log"; exit 1; } + echo "no device: SKIPPED without WAI_OPS_NPU_REQUIRE, a failure with it" diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 7b9892c29..43390a2cf 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -39,6 +39,8 @@ on: - 'wai/wai-pack-gpu/**' - 'wai/wai-rs/**' - 'wai/pack-conformance/**' + # tests/encode_rung.rs checks the encoded renditions of the base golden. + - 'wai/encode-conformance/rendition/**' - 'wai/claim-conformance/_blake3.py' - 'wai/claim-conformance/_ed25519.py' - 'wai/claim-conformance/claim_verify.py' @@ -137,6 +139,7 @@ jobs: --lib 39 --test=cmaf 4 --test=conformance 1 + --test=encode_rung 3 --test=fabric 3 --test=floor_only 2 --test=ladder 3 diff --git a/.github/workflows/wai-sink-android.yml b/.github/workflows/wai-sink-android.yml new file mode 100644 index 000000000..542617414 --- /dev/null +++ b/.github/workflows/wai-sink-android.yml @@ -0,0 +1,167 @@ +name: wai-sink-android native sink library + +# The native sink's Kotlin library (wai/wai-sink-android, extensions/native-sink.md). +# +# On the linux-arm64 runner, the platform-free half's unit tests on the host JVM: libwai and the +# JNI layer built for the runner, then the corpus live-conformance/native-sink through the JNI +# layer, every case and every refused configuration; the page-parity bundle byte for byte; the +# C interface's statuses as the JNI layer carries them; the loopback relay over the reference +# package's floor (presented byte for byte, a changed fragment answered 502 on every request, +# ranges, content codings, an unsigned playlist refused, nothing answered outside the session's +# token); and the observation loop's hidden and idle endings with its clock advanced by the test. +# No Android SDK is used there. +# +# On the macOS runner, the host where the platform SDK, its NDK and its emulator are installed: +# libwai and the JNI layer for each ABI with the pinned NDK, the AAR, and the instrumented tests in +# a headless emulator (the host's ABI, arm64-v8a): the corpus and the relay on the device, the +# platform's media player library playing the floor through the sink to its end, a changed +# fragment refused on every retry, a session nobody can see ended as hidden, and the attention +# signals read from a real activity. Nothing listens beyond loopback. +# +# Push filters match the push's delta, so every path the jobs read is listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-sink-android/**' + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/live-conformance/native-sink/**' + - 'wai/extensions/native-sink.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-sink-android.yml' + pull_request: + paths: + - 'wai/wai-sink-android/**' + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/live-conformance/native-sink/**' + - 'wai/extensions/native-sink.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-sink-android.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-sink-android.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-sink-android: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + jvm-linux-arm64: + name: the JNI layer and the platform-free half on the host JVM · linux-arm64 + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux-arm64 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # A JDK 17, from the image's packages when the image has none. + - name: JDK 17 + shell: bash + run: | + set -euo pipefail + j="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)" + if [ ! -x "$j/bin/javac" ]; then + sudo=$(command -v sudo || true) + $sudo apt-get update -qq + $sudo apt-get install -y -qq --no-install-recommends openjdk-17-jdk-headless >/dev/null + fi + test -f "$j/include/jni.h" || { echo "::error::no JDK 17 with jni.h at $j"; exit 1; } + echo "JAVA_HOME=$j" >> "$GITHUB_ENV" + - name: libwai and the JNI layer for this runner + shell: bash + working-directory: wai/wai-sink-android + run: bash scripts/build-native.sh host + # The pinned Gradle and the build's dependencies are kept on the runner's cache volume when + # it has one. + - name: The unit tests + shell: bash + working-directory: wai/wai-sink-android + run: | + set -euo pipefail + cache=/ci-cache/wai-sink-android + mkdir -p "$cache" 2>/dev/null || cache="$RUNNER_TEMP/wai-sink-android" + export WAI_SINK_GRADLE_DISTS="$cache/dists" GRADLE_USER_HOME="$cache/gradle-home" + bash scripts/gradle.sh -Pwai.sink.jvmOnly=true :core:test + python3 -I - core/build/test-results/test <<'PY' + import sys, pathlib, xml.etree.ElementTree as ET + runs = {} + tests = fails = skips = 0 + for f in pathlib.Path(sys.argv[1]).glob("*.xml"): + s = ET.parse(f).getroot() + tests += int(s.get("tests", 0)); fails += int(s.get("failures", 0)) + int(s.get("errors", 0)); skips += int(s.get("skipped", 0)) + runs[s.get("name").split(".")[-1]] = int(s.get("tests", 0)) + print(f"unit tests: {tests} run, {fails} failed, {skips} skipped: {runs}") + need = {"CorpusTest", "RelayTest", "SessionDriverTest", "JsonTest"} + missing = sorted(c for c in need if runs.get(c, 0) == 0) + if missing: print(f"::error::these classes ran no test: {missing}") + sys.exit(0 if tests > 0 and fails == 0 and skips == 0 and not missing else 1) + PY + + android-macos: + name: every ABI, the AAR, and the instrumented tests in a headless emulator · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: The targets the ABIs need + shell: bash + run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + - name: libwai and the JNI layer for each ABI + shell: bash + working-directory: wai/wai-sink-android + run: nice -n 10 bash scripts/build-native.sh android + - name: The AAR + shell: bash + working-directory: wai/wai-sink-android + run: | + set -euo pipefail + nice -n 10 bash scripts/gradle.sh :sink:assembleRelease + aar=sink/build/outputs/aar/sink-release.aar + # The listing is read whole, then searched: under pipefail, `unzip -l | grep -q` fails + # whenever grep stops at its first match before unzip has written the rest (SIGPIPE). + list="$(unzip -l "$aar")" + for abi in arm64-v8a armeabi-v7a x86_64; do + for so in libwai.so libwai_sink_jni.so; do + grep -q " jni/$abi/$so\$" <<<"$list" || { echo "::error::the AAR has no jni/$abi/$so"; echo "$list"; exit 1; } + done + done + grep -q ' classes.jar$' <<<"$list" || { echo "::error::the AAR has no classes"; echo "$list"; exit 1; } + - name: The instrumented tests in a headless emulator + shell: bash + working-directory: wai/wai-sink-android + run: nice -n 10 bash scripts/test-emulator.sh diff --git a/.github/workflows/wai-sink-swift.yml b/.github/workflows/wai-sink-swift.yml new file mode 100644 index 000000000..9ac3437f6 --- /dev/null +++ b/.github/workflows/wai-sink-swift.yml @@ -0,0 +1,119 @@ +name: wai-sink-swift native sink package + +# The native sink's Swift package (wai/wai-sink-swift, extensions/native-sink.md): on the macOS +# runner, the host with the platform SDKs and simulators, libwai is built for every slice of the +# package's binary target (desktop, phone, phone simulator, television, television simulator) and +# the package's tests run on the desktop, and in fresh phone and television simulators hosted in +# TestHost (a device runs no tool-hosted test bundle), every step bounded: the corpus +# live-conformance/native-sink through the Swift wrapper and the C interface; the loopback relay +# over the reference package's floor, presenting what verifies byte for byte and answering 502 +# for a fragment changed at the origin; and the platform's player end to end, over a presentation +# made at run time with the platform's own encoder, played through the relay to its end, a changed +# fragment withheld, and a session nobody can see ended as hidden. The phone and television device +# slices are built and linked, not run. Nothing listens beyond loopback. +# +# Push filters match the push's delta, so every path the jobs read is listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-sink-swift/**' + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/live-conformance/native-sink/**' + - 'wai/pack-conformance/packs/video-base-01/**' + - 'wai/extensions/native-sink.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-sink-swift.yml' + pull_request: + paths: + - 'wai/wai-sink-swift/**' + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-ops/**' + - 'wai/live-conformance/native-sink/**' + - 'wai/pack-conformance/packs/video-base-01/**' + - 'wai/extensions/native-sink.md' + - 'rust-toolchain.toml' + - '.github/workflows/wai-sink-swift.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux-arm64 + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-sink-swift.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-sink-swift: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + package-macos: + name: libwai slices, desktop tests, simulator tests · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: The targets the slices need + shell: bash + run: | + set -euo pipefail + rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + rustup component list --toolchain nightly-2026-10-17 --installed | grep -q '^rust-src' \ + || rustup toolchain install nightly-2026-10-17 --profile minimal --component rust-src + - name: WaiFFI.xcframework, every slice + shell: bash + working-directory: wai/wai-sink-swift + run: nice -n 10 bash scripts/build-xcframework.sh + - name: The desktop tests + shell: bash + working-directory: wai/wai-sink-swift + timeout-minutes: 20 + run: | + set -euo pipefail + nice -n 10 swift test 2>&1 | tee "$RUNNER_TEMP/swift-test.log" + grep -qE "Executed [1-9][0-9]* tests, with 0 failures" "$RUNNER_TEMP/swift-test.log" || { echo "::error::the desktop tests did not pass"; exit 1; } + # Each simulator run creates and boots a fresh simulator, builds the test host for it and runs + # the tests hosted in it, then shuts the simulator down and deletes it, however the run ends. + # Every step inside has a hard limit and fails, saying which, when it passes it; the step's + # own limit is the backstop, so no run holds the runner past it. + - name: The phone simulator tests, hosted in TestHost + shell: bash + working-directory: wai/wai-sink-swift + timeout-minutes: 30 + run: nice -n 10 bash scripts/test-simulator.sh ios + - name: The television simulator tests, hosted in TestHost + shell: bash + working-directory: wai/wai-sink-swift + timeout-minutes: 30 + run: nice -n 10 bash scripts/test-simulator.sh tvos + - name: The phone and television device slices link + shell: bash + working-directory: wai/wai-sink-swift + timeout-minutes: 20 + run: | + set -euo pipefail + for d in "generic/platform=iOS" "generic/platform=tvOS"; do + xcodebuild build -scheme WaiSink -destination "$d" -derivedDataPath .build/xc-device 2>&1 | tee "$RUNNER_TEMP/device.log" | grep -E "error:|\*\* BUILD" || true + grep -q '\*\* BUILD SUCCEEDED \*\*' "$RUNNER_TEMP/device.log" || { echo "::error::the package does not build for $d"; exit 1; } + done diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 8c297a808..224f50f44 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -135,6 +135,19 @@ jobs: cargo build --locked --release --target wasm32-unknown-unknown wasm-bindgen --target nodejs --out-dir demo/pkg-node --out-name wai_web \ target/wasm32-unknown-unknown/release/wai_web.wasm + # The platform bundle (feature platform_decode: platform-decode's query, the answer in + # browser-sink's report, and browser-sink §4.7's choice), a bundle of its own that the + # main one does not carry, so nothing above builds it. + - name: Build wai-web's platform bundle for wasm32 (feature platform_decode) + shell: bash + working-directory: wai/wai-web + run: | + set -euo pipefail + cargo build --locked --release --target wasm32-unknown-unknown --features platform_decode + wasm-bindgen --target web --out-dir demo/pkg-platform --out-name wai_platform_decode \ + target/wasm32-unknown-unknown/release/wai_web.wasm + test -s demo/pkg-platform/wai_platform_decode.js + grep -q "platformChoose" demo/pkg-platform/wai_platform_decode.js # The JS surface itself (pick, companionsOf, verifyPin, rendition() and # companion() refusing what is never presented or does not match its digest, # the held-sets conversion, and the WAI2 parse at 32-bit usize) runs only on @@ -194,15 +207,16 @@ jobs: # its import reads a program as the job service does and refuses one that # measures and then acts, its replies stay valid JSON whatever an error says, # and it signs with the page's own key once given one (quantum_toolchain::tests). The OTT demo seals a figure the page supplies as - # an estimate, never a reading (video_ott::tests). The count check turns a test that - # stopped being compiled into a failure instead of a silent "0 passed". + # an estimate, never a reading (video_ott::tests), and so does the shared world + # (world::tests). The count check turns a test that stopped being compiled into a + # failure instead of a silent "0 passed". - name: wai-web native unit tests shell: bash working-directory: wai/wai-web run: | set -euo pipefail cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" - grep -q "test result: ok. 35 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 36 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" - name: wasm32 int_synth decode equals the frozen native corpus shell: bash working-directory: wai/wai-web diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index c406fe3dd..acfbea7a7 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -4,6 +4,54 @@ All notable changes to the Joule Code standard are recorded here. The format fol ## [Unreleased] +### Added — `joule-moq-relay`: relay-to-relay cascading + +- **`[[upstream]]`**: relays this one dials, over native QUIC (`moqt://`), WebTransport + (`https://`) or the WebSocket binding (`wss://`, or `ws://` on a trusted path), each trusting a + CA file, a pinned certificate's SHA-256 or the platform's roots, presenting a token read from + a file and named by its SHA-256, and routing the namespace prefixes it lists. A link redials + after its session ends, backing off to 30 seconds. +- **Routing**: a SUBSCRIBE, FETCH or TRACK_STATUS for a track no session of the relay publishes + goes upstream once, on the first link of the configuration's order whose session is open; the + subscription it makes is the relay's own, cached and fanned out to every local subscriber; a + link that refuses or does not answer is passed over for the next, and when none answers the + request is refused `TIMEOUT` with `no_upstream`. Namespaces published to the relay, by + PUBLISH_NAMESPACE or by a track's PUBLISH, are published on every link that routes them, and + a SUBSCRIBE_NAMESPACE is forwarded on every link it overlaps, the upstream's NAMESPACE and + NAMESPACE_DONE merged with the relay's own. +- **Loops**: both ends of a link declare the Setup Option `RELAY_ID` (`0x3A4F`, the relay's + summary key), the dialer proving it over the listener's challenge; every request between + relays then carries `RELAY_PATH` (`0x3A51`, negotiated so, -22 §9.20), and no relay forwards + a request back where it came from, to a relay on its path, or past `limits.max_relay_hops`; + one that receives its own id is refused `relay_loop`. +- **GOAWAY** from an upstream moves the link's subscriptions to a session to the URI it names + (else the link's own) and closes the old one; it is never passed on downstream. The relay's + own GOAWAY on a session it dialed names no URI. +- **WAI evidence at every hop**: the door of every relay checks what a peer forwards; each + relay's per-hop record counts a link in its track entries and not as a subscriber session or + in its ledger; the floor and audience inputs are decided at the relay the performer or the + author publishes to, and read for their statements alone over a link; a FETCH or a fill of a + track the relay knows nothing of learns its live edge upstream first, so the seek power is + held at the viewer's relay. +- **`joule-moq`**: `Parameter::RelayPath` and `RELAY_ID_OPTION`; the bridges refuse the + parameter by name. +- `tests/cascade.rs`: two relays over each transport, a chain of three with a per-hop record + at each hop, a door refusing a tampered object another relay forwarded, a loop, failover and + `no_upstream`, GOAWAY, a DVR window fetched through an edge, and co-performance votes and the + floor across relays. + +### Added — `joule-moq`: WAI envelopes on LOC objects + +- **`joule-moq`**: module `loc` (WAI `extensions/loc-carriage.md`). An envelope whose payload + is one WebCodecs chunk (`wai.audio.opus.packet`, `wai.video.avc`, `wai.video.hevc`, + `wai.audio.aac`) is written as one object of a track of packaging `loc` + (`draft-ietf-moq-loc-04`): its payload as the LOC Payload, and in the Immutable Properties + the LOC properties, the manifest as `WAI_MANIFEST` (`0x3A61`) and the `WAI_RECEIPT` + property. `from_loc` reads an object back into the envelope, byte for byte, refusing with + the extension's codes. `examples/loc_vectors.rs` writes WAI's corpus `live-conformance/loc/` + and `tests/loc_vectors.rs` regenerates and checks it; WAI's `loc_verify.py` is its second + implementation. + ### Added — `joule-moq`: `draft-ietf-moq-transport-18` and `-16` beside `-22`, and runs against other implementations' relays - **`joule-moq`**: codec modules `draft18` and `draft16` (control messages, parameters, diff --git a/joule-code/joule-code-rs/Cargo.lock b/joule-code/joule-code-rs/Cargo.lock index c9890f0f6..209baf56a 100644 --- a/joule-code/joule-code-rs/Cargo.lock +++ b/joule-code/joule-code-rs/Cargo.lock @@ -1981,6 +1981,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "joule-contrib" +version = "0.1.0" +dependencies = [ + "aes", + "ctr", + "hex", + "hmac 0.12.1", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha1", + "sha2 0.10.9", + "tokio", + "tracing", +] + [[package]] name = "joule-moq" version = "0.3.0" @@ -2023,6 +2040,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "joule-contrib", "joule-moq", "joule-moq-relay", "quinn", @@ -2034,6 +2052,7 @@ dependencies = [ "str0m", "thiserror", "tokio", + "tokio-rustls", "toml", "tracing", "tracing-subscriber", @@ -2094,10 +2113,12 @@ dependencies = [ "rand_core 0.6.4", "ring", "rustls", + "rustls-native-certs", "serde", "serde_json", "thiserror", "tokio", + "tokio-rustls", "tokio-tungstenite", "toml", "tracing", @@ -4018,6 +4039,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" diff --git a/joule-code/joule-code-rs/Cargo.toml b/joule-code/joule-code-rs/Cargo.toml index 79d8bd137..6ff6c8ab1 100644 --- a/joule-code/joule-code-rs/Cargo.toml +++ b/joule-code/joule-code-rs/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/joule-moq-relay", "crates/joule-moq-interop", "crates/joule-moq-ingest", + "crates/joule-contrib", "crates/joule-moq-web", "crates/lockstep-quic", ] diff --git a/joule-code/joule-code-rs/crates/joule-contrib/Cargo.toml b/joule-code/joule-code-rs/crates/joule-contrib/Cargo.toml new file mode 100644 index 000000000..4726c2a23 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "joule-contrib" +version = "0.1.0" +description = "Contribution over UDP with retransmission: SRT (draft-sharabayko-srt-01) listener and caller with its latency window, ARQ and AES-CTR, and RIST (VSF TR-06-1 Simple and TR-06-2 Main profiles: RTP, RTCP retransmission requests, GRE-over-UDP tunnelling, PSK), with an MPEG-TS demultiplexer and an account of every sequence number's loss, recovery and latency." +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +repository = "https://github.com/transaction-science/open-standards" + +[lib] +path = "src/lib.rs" + +[features] +default = [] +# RIST (VSF TR-06-1, TR-06-2). Off by default: both texts carry an IPR notice on their +# protocol sections with a licensing assurance to implementers, and whether a default build +# carries the code is the owner's decision (README, "Patents"). +rist = [] + +[[bin]] +name = "joule-contrib-send" +path = "src/bin/send.rs" + +[[bin]] +name = "joule-contrib-recv" +path = "src/bin/recv.rs" + +[[bin]] +name = "joule-contrib-bench" +path = "src/bin/bench.rs" + +[dependencies] +# AES (FIPS 197) on the CPU's AES instructions where it has them, CTR mode (SP 800-38A), +# HMAC (RFC 2104) with SHA-1 and SHA-256: RustCrypto, MIT OR Apache-2.0. PBKDF2 (RFC 8018) +# and AES key wrap (RFC 3394) are written here over them. +aes = "0.8" +ctr = "0.9" +hmac = "0.12" +sha1 = "0.10" +sha2 = "0.10" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] } +rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +hex = "0.4" +tracing = "0.1" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/joule-code/joule-code-rs/crates/joule-contrib/README.md b/joule-code/joule-code-rs/crates/joule-contrib/README.md new file mode 100644 index 000000000..14dd4e686 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/README.md @@ -0,0 +1,84 @@ +# joule-contrib + +Contribution over UDP with retransmission, for WAI's ingest gateway: SRT +(`draft-sharabayko-srt-01`) and RIST (VSF TR-06-1:2020 Simple Profile, VSF TR-06-2:2024 Main +Profile), the MPEG transport stream both carry, and an account of what became of every +sequence number. WAI's `extensions/live-contribution.md` profiles them; `joule-moq-ingest` +uses this crate to bring a contribution link into a room's tracks. + +## SRT + +`srt::Listener` serves many callers on one UDP port, each connection found by the socket ID +its packets name; `srt::call` calls a listener. Both run the caller-listener handshake of the +draft's §4.3.1 (the SYN cookie, the HSv5 extensions, the stream ID, the key material), and +live mode (§7.1): every message one packet, TSBPD over the negotiated latency, too-late drop, +ACK/ACKACK with the RTT estimate, immediate and periodic NAKs, retransmission, AES-CTR with +the key wrapped (RFC 3394) under PBKDF2-HMAC-SHA1. A listener decides each caller through an +`Admission`, which sees the stream ID and gives the passphrase, the role (receive or send) and +the latency, or a rejection code. + +## RIST (feature `rist`) + +`rist::RistListener` receives one flow: the Simple Profile's RTP on an even port P and RTCP on +P + 1, or the Main Profile's GRE-over-UDP tunnel on one port, in reduced overhead mode with +the VSF EtherType or the legacy one, keep-alives, and PSK (PBKDF2-HMAC-SHA256 over the GRE +nonce, AES-CTR on the GRE sequence number). It takes the first flow whose CNAME is the one it +expects, and, under PSK, whose packets decrypt. It requests what is missing with range or +bitmask requests, measures the RTT with the RTT echo, and delivers each packet a buffer after +it was expected. `rist::send` is the sender of either profile. + +## The account + +`stats::Account` records what a receiver did with every sequence number, in order: delivered +at the first try, recovered within the window, dropped when its time passed, or outstanding +when the link ended; the loss runs, the requests sent and the numbers they named, the +retransmissions, duplicates and late arrivals received, the round trips, how long each +recovery took and its least margin. It is cut into parts, which the gateway signs as the link +record. + +## The transport stream + +`ts::Demux` reads the PAT and PMT (each section's CRC checked) and reassembles each stream's +PES packets, with the PTS extended past 33 bits; a PES missing a packet is dropped whole and +counted. `ts::Mux` writes a program of one video and one audio stream, and `ts::Pacer` times +a stream's chunks by its PCR. + +## Tools + +| binary | what | +|---|---| +| `joule-contrib-send` | sends a transport stream file (paced by its PCR) or a synthetic program over SRT (calling or listening) or RIST | +| `joule-contrib-recv` | receives over SRT or RIST into a file, and prints the link's account | +| `joule-contrib-bench` | one link on loopback, both ends in the process: the payload rate, CPU time and energy, with a lossy path if asked | + +URLs: `srt://HOST:PORT?streamid=…&passphrase=…&pbkeylen=16&latency=MS` (calls; `srt://:PORT` +listens), `rist://HOST:PORT?profile=simple|main&cname=…&secret=…&aes-type=128|256&buffer=MS`. + +## Tests + +| test | what | +|---|---| +| unit tests | RFC 3394, RFC 6070 and TR-06-2 Annex B vectors, SP 800-38A's CTR vector; the packet formats; the TR-06-1 Appendix A requests; the TS multiplexer against the demultiplexer, a lost packet, the PTS wrap; the account | +| `srt_link` | SRT on loopback through a proxy that drops datagrams each way, every payload in order, the account of every sequence number; each key length, a wrong and a missing passphrase, the rejection codes; a packet held for the latency window; a listener that sends to a caller that receives | +| `rist_link` | the Simple Profile with range and bitmask requests through lossy proxies; the Main Profile in the clear, under PSK of each length and in the legacy format; another CNAME and another passphrase refused | +| `interop` (ignored) | against each transport's reference tools run locally, named by `WAI_SRT_LIVE_TRANSMIT`, `WAI_RISTSENDER` and `WAI_RISTRECEIVER`: their caller and senders into this crate and this crate into their listener and receivers, byte for byte, encrypted and through lossy paths. With `--ignored`, a tool not named fails the test | + +The `lossy` module's proxy drops, delays and reorders datagrams from a fixed seed. + +## Patents + +VSF TR-06-1:2020 and TR-06-2:2024 each carry an intellectual property notice: a patent is +claimed to read on their protocol sections, and its holder has assured the forum that it will +license the claims to implementers. The RIST code is therefore built only with the feature +`rist`, which no default build turns on; whether a distributed build carries it is a licensing +decision for the distributor (`extensions/live-contribution.md` §10). + +## Dependencies + +AES, CTR, HMAC, SHA-1 and SHA-256 from RustCrypto (MIT OR Apache-2.0); PBKDF2 (RFC 8018) and +AES key wrap (RFC 3394) are written here over them. tokio, serde, serde_json, hex, tracing and +rand_core: MIT or Apache-2.0. No C library is linked. + +## Licence + +Apache-2.0. diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/bin/bench.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/bin/bench.rs new file mode 100644 index 000000000..9d3120c3e --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/bin/bench.rs @@ -0,0 +1,190 @@ +//! `joule-contrib-bench`: one link's throughput and energy on loopback, sender and receiver +//! in this process. +//! +//! ```text +//! joule-contrib-bench [--proto srt|rist-simple|rist-main] [--seconds N] [--mbps M | --max] +//! [--encrypt] [--loss-ppm N] [--latency-ms L] +//! ``` +//! +//! The sender sends 1316-byte payloads (seven TS packets) at `M` Mbit/s, or as fast as the +//! link takes them with `--max`; the receiver counts what it delivers. Through `--loss-ppm` +//! the datagrams pass a proxy that drops that share of them each way. It prints one JSON +//! line: the payload rate delivered, the link's account, the process's CPU time, and its +//! energy with the counter's profile, class and scope. Both ends are this process, so the +//! energy is the link's, sender and receiver together. + +use joule_contrib::energy; +use joule_contrib::lossy::{Impair, Proxy}; +use joule_contrib::srt::{self, Admitted, CallConfig, ListenerConfig, Role}; +use std::process::ExitCode; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let opt = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + let flag = |k: &str| args.iter().any(|a| a == k); + let proto = opt("--proto").unwrap_or_else(|| "srt".into()); + let seconds: u64 = opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(10); + let mbps: f64 = opt("--mbps").and_then(|v| v.parse().ok()).unwrap_or(20.0); + let max = flag("--max"); + let encrypt = flag("--encrypt"); + let loss: u32 = opt("--loss-ppm").and_then(|v| v.parse().ok()).unwrap_or(0); + let latency: u16 = opt("--latency-ms").and_then(|v| v.parse().ok()).unwrap_or(200); + let pass = "a bench passphrase".to_string(); + + let (tx, mut rx, proxy, _keep) = match proto.as_str() { + "srt" => { + let p2 = encrypt.then(|| pass.clone()); + let adm = move |_: &srt::ConnRequest| Ok(Admitted { passphrase: p2.clone(), role: Role::Receive, latency_ms: Some(latency), tag: 0 }); + let mut l = srt::Listener::bind(ListenerConfig { latency_ms: latency, flow_window: 25_600, ..ListenerConfig::default() }, Arc::new(adm)).await.expect("the listener binds"); + // The impairing proxy only where there is loss: it is a hop of its own. + let proxy = if loss > 0 { + let imp = Impair { loss_ppm: loss, ..Impair::default() }; + Some(Proxy::start(l.addr, imp, imp, 7).await.expect("the proxy starts")) + } else { + None + }; + let target = proxy.as_ref().map_or(l.addr, |p| p.addr); + let cfg = CallConfig { latency_ms: latency, passphrase: encrypt.then(|| pass.clone()), flow_window: 25_600, ..CallConfig::default() }; + let tx = srt::call(target, cfg).await.expect("the call connects"); + let rx = l.accept().await.expect("a link"); + (tx, rx, proxy, Keep::Srt(l)) + } + #[cfg(feature = "rist")] + p @ ("rist-simple" | "rist-main") => { + use joule_contrib::rist::{self, recv::RecvConfig, Profile, RistListener, RistListenerConfig, SendConfig}; + let profile = if p == "rist-main" { Profile::Main } else { Profile::Simple }; + let secret = (encrypt && profile == Profile::Main).then(|| pass.clone()); + let cfg = RistListenerConfig { profile, bind_ip: "127.0.0.1".parse().unwrap(), port: 0, recv: RecvConfig { buffer_us: latency as u64 * 1000, ..RecvConfig::default() }, secret: secret.clone(), expect_cname: None, tag: 0 }; + let mut l = RistListener::bind(cfg).await.expect("the receiver binds"); + if loss > 0 && profile == Profile::Simple { + eprintln!("joule-contrib-bench: --loss-ppm needs two proxies for the Simple Profile's two ports; running without loss"); + } + let (target, proxy) = if loss > 0 && profile == Profile::Main { + let imp = Impair { loss_ppm: loss, ..Impair::default() }; + let px = Proxy::start(l.addr, imp, imp, 7).await.expect("the proxy starts"); + (px.addr, Some(px)) + } else { + (l.addr, None) + }; + let tx = rist::send(target, SendConfig { profile, secret, buffer_ms: latency as u64 * 2, ..SendConfig::default() }).await.expect("the sender starts"); + let rx = l.accept().await.expect("a flow"); + (tx, rx, proxy, Keep::Rist(l)) + } + p => { + eprintln!("joule-contrib-bench: no protocol {p}"); + return ExitCode::from(2); + } + }; + + let payload = { + let mut p = vec![0u8; 1316]; + for (i, c) in p.chunks_mut(188).enumerate() { + c[0] = 0x47; + for (j, b) in c.iter_mut().enumerate().skip(1) { + *b = ((i * 31 + j * 7) % 251) as u8; + } + } + p + }; + let send = tx.send.clone().expect("a sending link"); + // On a counter every user of the package shares (linux-rapl), the figure is a bracket: + // the run's reading less an idle reading of the same length taken just before it. + let idle = match energy::read() { + Some(r) if r.scope == "bracket" => { + let t = std::time::Instant::now(); + tokio::time::sleep(Duration::from_secs(seconds)).await; + let r2 = energy::read().unwrap(); + Some((r.to(&r2), t.elapsed().as_secs_f64())) + } + _ => None, + }; + let e0 = energy::read(); + let c0 = energy::cpu_us(); + let start = Instant::now(); + let pps = mbps * 1e6 / 8.0 / 1316.0; + let sender = tokio::spawn(async move { + let mut sent = 0u64; + let mut tick = tokio::time::interval(Duration::from_millis(1)); + while start.elapsed() < Duration::from_secs(seconds) { + if max { + if send.send(payload.clone()).await.is_err() { + break; + } + sent += 1; + if sent.is_multiple_of(64) { + tokio::task::yield_now().await; + } + continue; + } + tick.tick().await; + let due = (start.elapsed().as_secs_f64() * pps) as u64; + while sent < due { + if send.send(payload.clone()).await.is_err() { + return sent; + } + sent += 1; + } + } + sent + }); + let mut got = 0u64; + let mut bytes = 0u64; + let end = start + Duration::from_secs(seconds) + Duration::from_millis(latency as u64 * 3 + 500); + while let Ok(Some(p)) = tokio::time::timeout(end.saturating_duration_since(Instant::now()), rx.data.recv()).await { + got += 1; + bytes += p.len() as u64; + } + let sent = sender.await.unwrap_or(0); + let wall = start.elapsed().as_secs_f64(); + let c1 = energy::cpu_us(); + let e1 = energy::read(); + let part = rx.account.lock().unwrap().cut(0, false); + let sent_counts = tx.sent.lock().unwrap().clone(); + tx.close(); + rx.close(); + let raw = match (&e0, &e1) { + (Some(a), Some(b)) => Some(a.to(b) as f64 / 1e9), + _ => None, + }; + // The idle reading scaled to the run's length, then taken off. + let idle_j = idle.map(|(nj, s)| nj as f64 / 1e9 * wall / s); + let joules = match (raw, idle_j) { + (Some(r), Some(i)) => Some(r - i), + (r, _) => r, + }; + let gb = bytes as f64 / 1e9; + let out = serde_json::json!({ + "proto": proto, + "encrypt": encrypt, + "seconds": seconds, + "target_mbps": if max { serde_json::Value::Null } else { mbps.into() }, + "sent": sent, + "delivered": got, + "delivered_mbps": bytes as f64 * 8.0 / 1e6 / (seconds as f64), + "wall_s": wall, + "cpu_s": (c1 - c0) as f64 / 1e6, + "loss_ppm": loss, + "proxy": proxy.as_ref().map(|p| serde_json::json!({"up_dropped": p.up.dropped.load(std::sync::atomic::Ordering::Relaxed), "down_dropped": p.down.dropped.load(std::sync::atomic::Ordering::Relaxed)})), + "account": part, + "sender": sent_counts, + "rtt_us": rx.rtt(), + "energy": e1.as_ref().map(|e| serde_json::json!({ + "profile": e.profile, "class": e.class, "scope": e.scope, + "joules": joules, "j_per_gb": joules.map(|j| if gb > 0.0 { j / gb } else { 0.0 }), + "raw_joules": raw, "idle_joules": idle_j, + "covers": if e.scope == "task" { "both ends of the link, one process" } else { "the package, less an idle bracket of the same length: both ends of the link and whatever else the package ran beyond its idle" }, + })), + }); + println!("{out}"); + ExitCode::SUCCESS +} + +#[allow(dead_code)] +enum Keep { + Srt(srt::Listener), + #[cfg(feature = "rist")] + Rist(joule_contrib::rist::RistListener), +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/bin/recv.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/bin/recv.rs new file mode 100644 index 000000000..cea77dde9 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/bin/recv.rs @@ -0,0 +1,93 @@ +//! `joule-contrib-recv`: receive a transport stream over SRT or RIST into a file. +//! +//! ```text +//! joule-contrib-recv [--out FILE.ts] [--seconds N] +//! srt://:PORT?passphrase=P&latency=MS listen for a caller (srt://HOST:PORT calls) +//! rist://:PORT?profile=simple|main&cname=C&secret=P&buffer=MS +//! ``` +//! +//! It writes every payload delivered, in order, and prints the link's account as one JSON +//! line when the link ends or the time is up. + +use joule_contrib::cli::{self, Url}; +use joule_contrib::srt::Role; +use std::io::Write; +use std::process::ExitCode; +use std::time::{Duration, Instant}; + +#[tokio::main] +async fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let Some(url) = args.first() else { + eprintln!("usage: joule-contrib-recv [--out FILE.ts] [--seconds N]"); + return ExitCode::from(2); + }; + let opt = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + let u = match Url::parse(url) { + Ok(u) => u, + Err(e) => { + eprintln!("joule-contrib-recv: {e}"); + return ExitCode::from(2); + } + }; + let seconds: u64 = opt("--seconds").and_then(|s| s.parse().ok()).unwrap_or(3600); + let link = match u.scheme.as_str() { + "srt" => cli::srt_link(&u, Role::Receive).await, + #[cfg(feature = "rist")] + "rist" => rist_link(&u).await, + s => Err(format!("no receiver for {s}://")), + }; + let mut link = match link { + Ok(l) => l, + Err(e) => { + eprintln!("joule-contrib-recv: {e}"); + return ExitCode::FAILURE; + } + }; + let mut out = opt("--out").map(|p| std::fs::File::create(p).expect("the output file opens")); + let deadline = Instant::now() + Duration::from_secs(seconds); + let mut bytes = 0u64; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(left, link.data.recv()).await { + Ok(Some(p)) => { + bytes += p.len() as u64; + if let Some(f) = out.as_mut() { + let _ = f.write_all(&p); + } + } + Ok(None) | Err(_) => break, + } + } + link.close(); + let _ = tokio::time::timeout(Duration::from_secs(2), link.wait_closed()).await; + while let Ok(p) = link.data.try_recv() { + bytes += p.len() as u64; + if let Some(f) = out.as_mut() { + let _ = f.write_all(&p); + } + } + let part = link.account.lock().unwrap().cut(0, true); + println!("{}", serde_json::json!({"protocol": link.info.protocol.name(), "bytes": bytes, "rtt_us": link.rtt(), "closed": link.closed_reason(), "account": part})); + ExitCode::SUCCESS +} + +#[cfg(feature = "rist")] +async fn rist_link(u: &Url) -> Result { + use joule_contrib::rist::{recv::RecvConfig, Profile, RistListener, RistListenerConfig}; + let addr = u.addr()?; + let cfg = RistListenerConfig { + profile: if u.get("profile") == Some("main") { Profile::Main } else { Profile::Simple }, + bind_ip: addr.ip(), + port: addr.port(), + recv: RecvConfig { buffer_us: u.num::("buffer", 1000) * 1000, ..RecvConfig::default() }, + secret: u.get("secret").map(str::to_string), + expect_cname: u.get("cname").map(str::to_string), + tag: 0, + }; + let mut l = RistListener::bind(cfg).await.map_err(|e| e.to_string())?; + eprintln!("listening on rist://{}", l.addr); + let link = l.accept().await.ok_or("the receiver stopped")?; + std::mem::forget(l); + Ok(link) +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/bin/send.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/bin/send.rs new file mode 100644 index 000000000..1691e1a37 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/bin/send.rs @@ -0,0 +1,84 @@ +//! `joule-contrib-send`: send a transport stream over SRT or RIST. +//! +//! ```text +//! joule-contrib-send [--input FILE.ts | --synthetic SECONDS [--kbps N]] [--linger MS] +//! srt://HOST:PORT?streamid=S&passphrase=P&pbkeylen=16&latency=MS call a listener +//! srt://:PORT?passphrase=P listen for a caller +//! rist://HOST:PORT?profile=simple|main&cname=C&secret=P&aes-type=128|256&buffer=MS&gre=vsf|legacy +//! ``` +//! +//! A file is sent at the pace its PCR gives, seven TS packets a datagram; `--synthetic` +//! makes a program of H.264-shaped access units and AAC frames instead. It prints what it +//! sent as one JSON line. + +use joule_contrib::cli::{self, Url}; +use joule_contrib::srt::Role; +use std::process::ExitCode; + +#[tokio::main] +async fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let Some(url) = args.first() else { + eprintln!("usage: joule-contrib-send [--input FILE.ts | --synthetic SECONDS [--kbps N]] [--linger MS]"); + return ExitCode::from(2); + }; + let opt = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned(); + let u = match Url::parse(url) { + Ok(u) => u, + Err(e) => { + eprintln!("joule-contrib-send: {e}"); + return ExitCode::from(2); + } + }; + let chunks = match (opt("--input"), opt("--synthetic")) { + (Some(f), _) => match cli::from_file(&f) { + Ok(c) => c, + Err(e) => { + eprintln!("joule-contrib-send: {e}"); + return ExitCode::FAILURE; + } + }, + (None, s) => cli::synthetic(s.and_then(|s| s.parse().ok()).unwrap_or(5), opt("--kbps").and_then(|k| k.parse().ok()).unwrap_or(4000)), + }; + let linger: u64 = opt("--linger").and_then(|v| v.parse().ok()).unwrap_or(1500); + let link = match u.scheme.as_str() { + "srt" => cli::srt_link(&u, Role::Send).await, + #[cfg(feature = "rist")] + "rist" => rist_link(&u).await, + s => Err(format!("no sender for {s}://")), + }; + let mut link = match link { + Ok(l) => l, + Err(e) => { + eprintln!("joule-contrib-send: {e}"); + return ExitCode::FAILURE; + } + }; + let r = cli::pace(&link, &chunks, linger).await; + link.close(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), link.wait_closed()).await; + let sent = link.sent.lock().unwrap().clone(); + println!("{}", serde_json::json!({"protocol": link.info.protocol.name(), "chunks": chunks.len(), "sent": sent, "rtt_us": link.rtt()})); + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("joule-contrib-send: {e}"); + ExitCode::FAILURE + } + } +} + +#[cfg(feature = "rist")] +async fn rist_link(u: &Url) -> Result { + use joule_contrib::rist::{self, Profile, SendConfig}; + let cfg = SendConfig { + profile: if u.get("profile") == Some("main") { Profile::Main } else { Profile::Simple }, + cname: u.get("cname").unwrap_or("wai-contrib").to_string(), + secret: u.get("secret").map(str::to_string), + aes_256: u.get("aes-type") == Some("256"), + buffer_ms: u.num("buffer", 1000), + idle_ms: 10_000, + vsf: u.get("gre") != Some("legacy"), + }; + rist::send(u.addr()?, cfg).await.map_err(|e| e.to_string()) +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/cli.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/cli.rs new file mode 100644 index 000000000..86e61c11f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/cli.rs @@ -0,0 +1,178 @@ +//! What the command-line tools share: their URLs, their sources of transport stream, and +//! links opened from a URL. +//! +//! ```text +//! srt://HOST:PORT[?streamid=S&passphrase=P&pbkeylen=16|24|32&latency=MS&mode=caller|listener] +//! rist://HOST:PORT[?profile=simple|main&cname=C&secret=P&aes-type=128|256&buffer=MS] +//! ``` +//! +//! An SRT URL calls by default; `mode=listener` (or an empty host, `srt://:PORT`) listens. + +use crate::link::Link; +use crate::srt::{self, Admitted, CallConfig, ListenerConfig, Role}; +use crate::ts::{Mux, Pacer, ST_H264}; +use std::collections::BTreeMap; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct Url { + pub scheme: String, + pub host: String, + pub port: u16, + pub q: BTreeMap, +} + +impl Url { + pub fn parse(s: &str) -> Result { + let (scheme, rest) = s.split_once("://").ok_or("a URL is scheme://host:port")?; + let (hp, q) = rest.split_once('?').unwrap_or((rest, "")); + let (host, port) = hp.rsplit_once(':').ok_or("a URL names a port")?; + let port: u16 = port.trim_end_matches('/').parse().map_err(|_| "the port is a number")?; + let host = host.trim_start_matches('[').trim_end_matches(']').to_string(); + let mut m = BTreeMap::new(); + for kv in q.split('&').filter(|x| !x.is_empty()) { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + m.insert(k.to_string(), percent_decode(v)); + } + Ok(Url { scheme: scheme.to_ascii_lowercase(), host, port, q: m }) + } + + pub fn addr(&self) -> Result { + let h = if self.host.is_empty() { "0.0.0.0" } else { &self.host }; + (h, self.port).to_socket_addrs().map_err(|e| e.to_string())?.next().ok_or_else(|| "the host does not resolve".into()) + } + + pub fn get(&self, k: &str) -> Option<&str> { + self.q.get(k).map(String::as_str) + } + + pub fn num(&self, k: &str, d: T) -> T { + self.get(k).and_then(|v| v.parse().ok()).unwrap_or(d) + } + + pub fn listens(&self) -> bool { + self.get("mode") == Some("listener") || self.host.is_empty() + } +} + +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut o = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && i + 2 < b.len() + && let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or(""), 16) + { + o.push(v); + i += 3; + } else { + o.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&o).into_owned() +} + +/// Open an SRT link from `u` in role `role`: call, or listen and take the first caller. +pub async fn srt_link(u: &Url, role: Role) -> Result { + let latency_ms: u16 = u.num("latency", 120); + let passphrase = u.get("passphrase").map(str::to_string); + let keylen: usize = u.num("pbkeylen", 16); + if u.listens() { + let p2 = passphrase.clone(); + let adm = move |_: &srt::ConnRequest| Ok(Admitted { passphrase: p2.clone(), role, latency_ms: None, tag: 0 }); + let cfg = ListenerConfig { bind: u.addr()?, latency_ms, advertise_keylen: if passphrase.is_some() { keylen } else { 0 }, ..ListenerConfig::default() }; + let mut l = srt::Listener::bind(cfg, Arc::new(adm)).await.map_err(|e| e.to_string())?; + eprintln!("listening on srt://{}", l.addr); + let link = l.accept().await.ok_or("the listener stopped")?; + // The listener keeps serving the link's packets until it ends. + std::mem::forget(l); + Ok(link) + } else { + let cfg = CallConfig { latency_ms, passphrase, keylen, stream_id: u.get("streamid").map(str::to_string), role, ..CallConfig::default() }; + srt::call(u.addr()?, cfg).await.map_err(|e| e.to_string()) + } +} + +/// A synthetic program for `seconds`: one H.264-shaped access unit every 1/30 s (a +/// keyframe each second) and two ADTS frames of AAC every 1/25 s, multiplexed and timed. +/// The bytes are not decodable media; they have the shape of it. +pub fn synthetic(seconds: u64, kbps: u64) -> Vec<(u64, Vec)> { + let mut m = Mux::new(ST_H264); + let mut ts = Vec::new(); + let frames = seconds * 30; + let per_frame = (kbps * 1000 / 8 / 30).max(200) as usize; + let mut a = 0u64; + for i in 0..frames { + let key = i % 30 == 0; + let mut au = vec![0, 0, 0, 1, 0x09, 0xf0]; + if key { + au.extend_from_slice(&[0, 0, 0, 1, 0x67, 0x64, 0x00, 0x28, 0xac, 0, 0, 0, 1, 0x68, 0xee, 0x3c, 0x80, 0, 0, 0, 1, 0x65]); + } else { + au.extend_from_slice(&[0, 0, 0, 1, 0x41]); + } + let n = if key { per_frame * 3 } else { per_frame }; + au.extend((0..n).map(|j| ((i as usize * 131 + j * 7) % 251 + 1) as u8)); + let pts = 126_000 + i * 3000; + ts.extend(m.pes(true, pts, &au, key)); + // Audio up to this frame's time: two frames of 1024 samples at 48 kHz a PES, 3840 + // ticks of 90 kHz. + while a * 3840 <= i * 3000 { + let mut f = Vec::new(); + for k in 0..2 { + let body: Vec = (0..160).map(|j| ((a * 2 + k + j) % 200 + 9) as u8).collect(); + let len = body.len() + 7; + f.extend_from_slice(&[0xff, 0xf1, (1 << 6) | (3 << 2), (2 << 6) | ((len >> 11) as u8 & 0x3), (len >> 3) as u8, ((len as u8 & 0x7) << 5) | 0x1f, 0xfc]); + f.extend(body); + } + ts.extend(m.pes(false, 126_000 + a * 3840, &f, false)); + a += 1; + } + } + Pacer::new(&ts, 7).chunks().to_vec() +} + +/// A transport stream file's chunks of seven packets, timed by its PCR. +pub fn from_file(path: &str) -> Result)>, String> { + let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + Ok(Pacer::new(&b, 7).chunks().to_vec()) +} + +/// Send `chunks` on `link`, each at its time from now; then wait `linger_ms` and close. +pub async fn pace(link: &Link, chunks: &[(u64, Vec)], linger_ms: u64) -> Result<(), String> { + let tx = link.send.clone().ok_or("the link does not send")?; + let start = tokio::time::Instant::now(); + for (at, c) in chunks { + tokio::time::sleep_until(start + std::time::Duration::from_micros(*at)).await; + if tx.send(c.clone()).await.is_err() { + return Err(link.closed_reason().unwrap_or_else(|| "the link ended".into())); + } + } + tokio::time::sleep(std::time::Duration::from_millis(linger_ms)).await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn urls_read() { + let u = Url::parse("srt://127.0.0.1:9000?streamid=%23!%3A%3Ar%3Dx,m%3Dpublish&passphrase=0123456789&latency=200").unwrap(); + assert_eq!((u.scheme.as_str(), u.port), ("srt", 9000)); + assert_eq!(u.get("streamid"), Some("#!::r=x,m=publish")); + assert_eq!(u.num::("latency", 120), 200); + assert!(!u.listens()); + assert!(Url::parse("srt://:9000").unwrap().listens()); + assert!(Url::parse("rist://[::1]:5000?profile=main").unwrap().addr().unwrap().is_ipv6()); + } + + #[test] + fn a_synthetic_stream_is_timed() { + let c = synthetic(2, 2000); + assert!(c.len() > 100); + assert!(c.last().unwrap().0 > 1_800_000); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/crypto.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/crypto.rs new file mode 100644 index 000000000..2c8231598 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/crypto.rs @@ -0,0 +1,225 @@ +//! The cryptography both transports use, over RustCrypto's AES, CTR, HMAC, SHA-1 and +//! SHA-256: +//! +//! - **PBKDF2** (RFC 8018 §5.2) with HMAC-SHA1 (SRT's key-encrypting key, +//! `draft-sharabayko-srt-01` §6.1.4: 2048 iterations over the last 64 bits of the salt) and +//! HMAC-SHA256 (RIST's PSK key, VSF TR-06-2 §7.3: 1024 iterations over the GRE nonce). +//! - **AES key wrap** (RFC 3394 §2.2.1, §2.2.2, with the default initial value A6A6A6A6A6A6A6A6): +//! SRT's stream-encrypting keys travel wrapped under the key-encrypting key. +//! - **AES-CTR** (SP 800-38A §6.5) with a 128-bit big-endian counter: SRT's payload +//! (§6.2.2) and RIST's GRE payload (§7.2). + +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, KeyIvInit, StreamCipher}; +use hmac::{Hmac, Mac}; + +/// PBKDF2 (RFC 8018 §5.2) with the HMAC `$mac`: `len` bytes from `password`, `salt` and +/// `iterations`. +macro_rules! pbkdf2 { + ($name:ident, $mac:ty) => { + pub fn $name(password: &[u8], salt: &[u8], iterations: u32, len: usize) -> Vec { + let prf = <$mac as Mac>::new_from_slice(password).expect("HMAC takes a key of any length"); + let mut out = Vec::with_capacity(len); + let mut block = 1u32; + while out.len() < len { + let mut m = prf.clone(); + m.update(salt); + m.update(&block.to_be_bytes()); + let mut u = m.finalize().into_bytes(); + let mut t = u; + for _ in 1..iterations.max(1) { + let mut m = prf.clone(); + m.update(&u); + u = m.finalize().into_bytes(); + for (a, b) in t.iter_mut().zip(u.iter()) { + *a ^= b; + } + } + out.extend_from_slice(&t); + block += 1; + } + out.truncate(len); + out + } + }; +} + +pbkdf2!(pbkdf2_hmac_sha1, Hmac); +pbkdf2!(pbkdf2_hmac_sha256, Hmac); + +/// An AES key of 128, 192 or 256 bits, expanded. +#[derive(Clone)] +pub enum AesKey { + K128(aes::Aes128), + K192(aes::Aes192), + K256(aes::Aes256), +} + +impl AesKey { + /// `None` unless `key` is 16, 24 or 32 bytes. + pub fn new(key: &[u8]) -> Option { + Some(match key.len() { + 16 => AesKey::K128(aes::Aes128::new_from_slice(key).ok()?), + 24 => AesKey::K192(aes::Aes192::new_from_slice(key).ok()?), + 32 => AesKey::K256(aes::Aes256::new_from_slice(key).ok()?), + _ => return None, + }) + } + + fn encrypt(&self, b: &mut [u8; 16]) { + let g = aes::Block::from_mut_slice(b); + match self { + AesKey::K128(k) => k.encrypt_block(g), + AesKey::K192(k) => k.encrypt_block(g), + AesKey::K256(k) => k.encrypt_block(g), + } + } + + fn decrypt(&self, b: &mut [u8; 16]) { + let g = aes::Block::from_mut_slice(b); + match self { + AesKey::K128(k) => k.decrypt_block(g), + AesKey::K192(k) => k.decrypt_block(g), + AesKey::K256(k) => k.decrypt_block(g), + } + } +} + +/// RFC 3394's default initial value. +pub const KW_IV: [u8; 8] = [0xa6; 8]; + +/// AES key wrap (RFC 3394 §2.2.1) of `plain` (a multiple of 8 bytes, at least 16) under +/// `kek`: 8 bytes longer than `plain`. +pub fn key_wrap(kek: &AesKey, plain: &[u8]) -> Option> { + if plain.len() < 16 || !plain.len().is_multiple_of(8) { + return None; + } + let n = plain.len() / 8; + let mut a = KW_IV; + let mut r: Vec<[u8; 8]> = plain.chunks(8).map(|c| c.try_into().unwrap()).collect(); + for j in 0..6u64 { + for (i, ri) in r.iter_mut().enumerate() { + let mut b = [0u8; 16]; + b[..8].copy_from_slice(&a); + b[8..].copy_from_slice(ri); + kek.encrypt(&mut b); + let t = (n as u64) * j + i as u64 + 1; + a.copy_from_slice(&b[..8]); + for (x, y) in a.iter_mut().zip(t.to_be_bytes()) { + *x ^= y; + } + ri.copy_from_slice(&b[8..]); + } + } + let mut out = a.to_vec(); + for ri in r { + out.extend_from_slice(&ri); + } + Some(out) +} + +/// AES key unwrap (RFC 3394 §2.2.2): `None` when `wrapped` is not a wrap of the right +/// shape or its integrity check fails, that is, when `kek` is not the key it was wrapped +/// under. +pub fn key_unwrap(kek: &AesKey, wrapped: &[u8]) -> Option> { + if wrapped.len() < 24 || !wrapped.len().is_multiple_of(8) { + return None; + } + let n = wrapped.len() / 8 - 1; + let mut a: [u8; 8] = wrapped[..8].try_into().unwrap(); + let mut r: Vec<[u8; 8]> = wrapped[8..].chunks(8).map(|c| c.try_into().unwrap()).collect(); + for j in (0..6u64).rev() { + for i in (0..n).rev() { + let t = (n as u64) * j + i as u64 + 1; + let mut b = [0u8; 16]; + for (k, y) in t.to_be_bytes().iter().enumerate() { + b[k] = a[k] ^ y; + } + b[8..].copy_from_slice(&r[i]); + kek.decrypt(&mut b); + a.copy_from_slice(&b[..8]); + r[i].copy_from_slice(&b[8..]); + } + } + // The check compares every byte, so it takes the same time whichever byte differs. + if a.iter().zip(KW_IV.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) != 0 { + return None; + } + Some(r.concat()) +} + +/// AES-CTR (SP 800-38A §6.5) in place over `data`, from the counter block `iv`, the +/// counter the whole 128-bit block incremented big-endian. +pub fn ctr_apply(key: &[u8], iv: &[u8; 16], data: &mut [u8]) -> bool { + match key.len() { + 16 => ctr::Ctr128BE::::new(key.into(), iv.into()).apply_keystream(data), + 24 => ctr::Ctr128BE::::new(key.into(), iv.into()).apply_keystream(data), + 32 => ctr::Ctr128BE::::new(key.into(), iv.into()).apply_keystream(data), + _ => return false, + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(s: &str) -> Vec { + hex::decode(s.replace(' ', "")).unwrap() + } + + #[test] + fn pbkdf2_sha1_rfc6070() { + assert_eq!(pbkdf2_hmac_sha1(b"password", b"salt", 1, 20), h("0c60c80f961f0e71f3a9b524af6012062fe037a6")); + assert_eq!(pbkdf2_hmac_sha1(b"password", b"salt", 2, 20), h("ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957")); + assert_eq!(pbkdf2_hmac_sha1(b"password", b"salt", 4096, 20), h("4b007901b765489abead49d926f721d065a429c1")); + assert_eq!( + pbkdf2_hmac_sha1(b"passwordPASSWORDpassword", b"saltSALTsaltSALTsaltSALTsaltSALTsalt", 4096, 25), + h("3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038") + ); + } + + #[test] + fn pbkdf2_sha256_tr06_2_annex_b() { + let k = pbkdf2_hmac_sha256(b"Reliable Internet Stream Transport", &[0x52, 0x49, 0x53, 0x54], 1024, 32); + assert_eq!(k, h("1c2b0cfc90ae2638fea78c7fb297704718bff7f4052743001a9b7ebb51cc9f1c")); + assert_eq!(&k[..16], &h("1c2b0cfc90ae2638fea78c7fb2977047")[..]); + } + + #[test] + fn key_wrap_rfc3394() { + // §4.1: a 128-bit key under a 128-bit KEK. + let kek = AesKey::new(&h("000102030405060708090A0B0C0D0E0F")).unwrap(); + let w = key_wrap(&kek, &h("00112233445566778899AABBCCDDEEFF")).unwrap(); + assert_eq!(w, h("1FA68B0A8112B447 AEF34BD8FB5A7B82 9D3E862371D2CFE5")); + assert_eq!(key_unwrap(&kek, &w).unwrap(), h("00112233445566778899AABBCCDDEEFF")); + // §4.6: a 256-bit key under a 256-bit KEK. + let kek = AesKey::new(&h("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F")).unwrap(); + let key = h("00112233445566778899AABBCCDDEEFF000102030405060708090A0B0C0D0E0F"); + let w = key_wrap(&kek, &key).unwrap(); + assert_eq!(w, h("28C9F404C4B810F4 CBCCB35CFB87F826 3F5786E2D80ED326 CBC7F0E71A99F43B FB988B9B7A02DD21")); + assert_eq!(key_unwrap(&kek, &w).unwrap(), key); + // §4.2: a 128-bit key under a 192-bit KEK. + let kek = AesKey::new(&h("000102030405060708090A0B0C0D0E0F1011121314151617")).unwrap(); + assert_eq!(key_wrap(&kek, &h("00112233445566778899AABBCCDDEEFF")).unwrap(), h("96778B25AE6CA435 F92B5B97C050AED2 468AB8A17AD84E5D")); + } + + #[test] + fn unwrap_under_another_key_fails() { + let kek = AesKey::new(&[1; 16]).unwrap(); + let w = key_wrap(&kek, &[7; 16]).unwrap(); + assert!(key_unwrap(&AesKey::new(&[2; 16]).unwrap(), &w).is_none()); + let mut bad = w.clone(); + bad[9] ^= 1; + assert!(key_unwrap(&kek, &bad).is_none()); + } + + #[test] + fn ctr_sp800_38a_f5_1() { + // SP 800-38A F.5.1, CTR-AES128.Encrypt, the first two blocks. + let key = h("2b7e151628aed2a6abf7158809cf4f3c"); + let iv: [u8; 16] = h("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff").try_into().unwrap(); + let mut d = h("6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51"); + assert!(ctr_apply(&key, &iv, &mut d)); + assert_eq!(d, h("874d6191b620e3261bef6864990db6ce9806f66b7970fdff8617187bb9fffdff")); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/energy.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/energy.rs new file mode 100644 index 000000000..052cad869 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/energy.rs @@ -0,0 +1,121 @@ +//! The energy counters the benches read, under the names of WAI's sink energy profiles +//! (PR #460, `energy-measurement` §9 there): what a reading covers is its profile's, and a +//! figure is never stated without its class and scope. +//! +//! - `macos-task`: `proc_pid_rusage` at `RUSAGE_INFO_V6` for this process, +//! `ri_energy_nj + ri_billed_energy − ri_serviced_energy`: the operating system's account of +//! the process's own energy (scope `task`), an on-chip counter. +//! - `linux-rapl`: the package zones of the powercap interface, summed: every user of the +//! package shares them, so a figure is a bracket (scope `bracket`), the run's reading less +//! an idle reading of the same length taken before it. + +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +pub struct Reading { + pub profile: &'static str, + pub scope: &'static str, + pub class: &'static str, + pub nj: u64, + /// Where the counter wraps, nJ, if it does. + pub wrap_nj: Option, +} + +impl Reading { + /// The energy from `self` to `later`, across one wrap of the counter. + pub fn to(&self, later: &Reading) -> u64 { + match (later.nj >= self.nj, self.wrap_nj) { + (true, _) => later.nj - self.nj, + (false, Some(w)) => w - self.nj + later.nj, + (false, None) => 0, + } + } +} + +#[cfg(target_os = "macos")] +mod mac { + use std::ffi::{c_int, c_void}; + unsafe extern "C" { + fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int; + fn getpid() -> c_int; + } + const RUSAGE_INFO_V6: c_int = 6; + // Byte offsets in `struct rusage_info_v6`. + const BILLED: usize = 264; + const SERVICED: usize = 272; + const ENERGY: usize = 336; + + pub fn task_nj() -> Option { + let mut buf = [0u64; 128]; + // SAFETY: the buffer is larger than `struct rusage_info_v6`. + let r = unsafe { proc_pid_rusage(getpid(), RUSAGE_INFO_V6, buf.as_mut_ptr().cast()) }; + if r != 0 { + return None; + } + let at = |off: usize| buf[off / 8]; + Some((at(ENERGY) + at(BILLED)).saturating_sub(at(SERVICED))) + } +} + +/// The package zones of the powercap interface, in µJ, summed, and where the sum wraps; +/// `None` where none is readable (the files are root-only on most kernels). Only the first +/// zone's wrap is kept: one package is what the hosts here have. +#[cfg(target_os = "linux")] +fn rapl_uj() -> Option<(u64, Option)> { + let mut total = 0u64; + let mut wrap = None; + let mut any = false; + for i in 0..8 { + let p = format!("/sys/class/powercap/intel-rapl:{i}/energy_uj"); + if let Ok(s) = std::fs::read_to_string(&p) + && let Ok(v) = s.trim().parse::() + { + total += v; + any = true; + if wrap.is_none() { + wrap = std::fs::read_to_string(format!("/sys/class/powercap/intel-rapl:{i}/max_energy_range_uj")).ok().and_then(|s| s.trim().parse::().ok()); + } + } + } + any.then_some((total, wrap)) +} + +/// One reading of the best counter this host has for this process. +pub fn read() -> Option { + #[cfg(target_os = "macos")] + { + mac::task_nj().map(|nj| Reading { profile: "macos-task", scope: "task", class: "OnChipCounter", nj, wrap_nj: None }) + } + #[cfg(target_os = "linux")] + { + rapl_uj().map(|(uj, w)| Reading { profile: "linux-rapl", scope: "bracket", class: "OnChipCounter", nj: uj * 1000, wrap_nj: w.map(|w| w * 1000) }) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + None + } +} + +/// The process's CPU time so far, user and system, µs. +pub fn cpu_us() -> u64 { + #[repr(C)] + struct Timeval { + sec: i64, + usec: i64, + } + #[repr(C)] + struct Rusage { + utime: Timeval, + stime: Timeval, + rest: [i64; 14], + } + unsafe extern "C" { + fn getrusage(who: i32, usage: *mut Rusage) -> i32; + } + let mut r = Rusage { utime: Timeval { sec: 0, usec: 0 }, stime: Timeval { sec: 0, usec: 0 }, rest: [0; 14] }; + // SAFETY: `Rusage` has the layout of `struct rusage` on 64-bit Linux and macOS. + if unsafe { getrusage(0, &mut r) } != 0 { + return 0; + } + (r.utime.sec * 1_000_000 + r.utime.usec + r.stime.sec * 1_000_000 + r.stime.usec).max(0) as u64 +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/lib.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/lib.rs new file mode 100644 index 000000000..92b4203db --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/lib.rs @@ -0,0 +1,61 @@ +//! **joule-contrib**: contribution over UDP with retransmission. +//! +//! - **SRT** ([`srt`], `draft-sharabayko-srt-01`): a listener that demultiplexes many +//! callers on one UDP port by socket ID, and a caller; the caller-listener handshake +//! with its SYN cookie, the HSv5 extensions (`HSREQ`/`HSRSP`, the stream ID, the key +//! material), live mode: timestamp-based packet delivery (TSBPD) over the negotiated +//! latency window, too-late packet drop, ACK/ACKACK with the RTT estimate, immediate +//! and periodic NAKs and retransmission, and AES-CTR with the key wrapped under a +//! passphrase-derived key (PBKDF2-HMAC-SHA1, RFC 3394). +//! - **RIST** ([`rist`], feature `rist`; VSF TR-06-1:2020 and TR-06-2:2024): the Simple +//! Profile's RTP on port P and RTCP on P+1 with sender and receiver reports, CNAME, +//! bitmask (RFC 4585) and range retransmission requests, retransmissions marked by the +//! SSRC's low bit and the RTT echo; and the Main Profile's GRE-over-UDP tunnel (RFC +//! 8086) on one port in reduced overhead mode with the VSF EtherType, its keep-alive, +//! and PSK encryption (PBKDF2-HMAC-SHA256 over the GRE nonce, AES-CTR on the GRE +//! sequence number). +//! - **MPEG-TS** ([`ts`], ISO/IEC 13818-1): the payload both carry. A demultiplexer that +//! reads the PAT and PMT and hands over each PES's access unit with its PTS extended past +//! 33 bits, and a multiplexer and a PCR pacer for the senders and the tests. +//! - **The account** ([`stats`]): every sequence number a receiver passes is either +//! delivered (at the first try, or recovered after it was missed) or dropped when its +//! delivery time passed, or still outstanding when the link ended; with the retransmission +//! requests, the RTT samples and how long each recovery took. The gateway signs it as a +//! link record. +//! - **An impaired path** ([`lossy`]): a UDP proxy that drops, delays and reorders +//! datagrams from a fixed seed, for the tests and the benches. +//! +//! The crypto is RustCrypto's (AES on the CPU's AES instructions where present, CTR, +//! HMAC, SHA-1, SHA-256); PBKDF2 and AES key wrap are written in [`crypto`] over them. + +pub mod cli; +pub mod crypto; +pub mod energy; +pub mod link; +pub mod lossy; +pub mod seq; +pub mod srt; +pub mod stats; +pub mod ts; + +#[cfg(feature = "rist")] +pub mod rist; + +/// Microseconds on a monotonic clock shared by a connection's parts. +pub fn micros(since: std::time::Instant) -> u64 { + since.elapsed().as_micros() as u64 +} + +/// A random 32-bit value from the operating system's generator. +pub fn random_u32() -> u32 { + use rand_core_06::RngCore; + rand_core_06::OsRng.next_u32() +} + +/// `n` random bytes from the operating system's generator. +pub fn random_bytes(n: usize) -> Vec { + use rand_core_06::RngCore; + let mut b = vec![0u8; n]; + rand_core_06::OsRng.fill_bytes(&mut b); + b +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/link.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/link.rs new file mode 100644 index 000000000..7a680f8ca --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/link.rs @@ -0,0 +1,163 @@ +//! A link: one established SRT connection or RIST flow, as the application holds it. +//! +//! A receiving link hands over its payloads in sequence order at their delivery time on +//! [`Link::data`] and keeps its [`Account`]; a sending link takes payloads on +//! [`Link::send`]. Either ends with a reason on [`Link::closed`]. + +use crate::stats::Account; +use serde::Serialize; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::{mpsc, watch}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum Protocol { + Srt, + RistSimple, + RistMain, +} + +impl Protocol { + pub fn code(self) -> u8 { + match self { + Protocol::Srt => 1, + Protocol::RistSimple => 2, + Protocol::RistMain => 3, + } + } + pub fn name(self) -> &'static str { + match self { + Protocol::Srt => "srt", + Protocol::RistSimple => "rist-simple", + Protocol::RistMain => "rist-main", + } + } +} + +/// Which end opened the link: the local end listened, or it called. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum Mode { + Listener, + Caller, +} + +impl Mode { + pub fn code(self) -> u8 { + match self { + Mode::Listener => 1, + Mode::Caller => 2, + } + } +} + +/// What the link is. +#[derive(Clone, Debug, Serialize)] +pub struct LinkInfo { + pub protocol: Protocol, + pub mode: Mode, + pub peer: SocketAddr, + /// SRT: the caller's stream ID. + pub stream_id: Option, + /// RIST: the sender's RTCP CNAME. + pub cname: Option, + /// The receive window: SRT's negotiated TSBPD latency, RIST's receiver buffer. + pub latency_us: u64, + /// The AES key length in bits; 0 for a link in the clear. + pub cipher_bits: u16, + /// What the application's admission gave the link. + pub tag: u64, +} + +/// What a sending link counted. +#[derive(Clone, Debug, Default, Serialize)] +pub struct SendCounts { + pub sent: u64, + pub bytes: u64, + pub retransmitted: u64, + pub requests: u64, + pub requested: u64, + /// Packets dropped from the send buffer before they were acknowledged, too old to be + /// of use. + pub expired: u64, +} + +pub enum Cmd { + Close, +} + +/// One link. +pub struct Link { + pub info: LinkInfo, + /// A receiving link's payloads, in order, at their delivery time. + pub data: mpsc::Receiver>, + /// A sending link's payloads to send, one packet each. + pub send: Option>>, + pub account: Arc>, + pub sent: Arc>, + /// The latest round-trip estimate, µs. + pub rtt_us: Arc, + pub closed: watch::Receiver>, + pub cmd: mpsc::UnboundedSender, +} + +impl Link { + pub fn close(&self) { + let _ = self.cmd.send(Cmd::Close); + } + + /// Why the link ended, once it has. + pub fn closed_reason(&self) -> Option { + self.closed.borrow().clone() + } + + /// Wait for the link to end; its reason. + pub async fn wait_closed(&mut self) -> String { + loop { + if let Some(r) = self.closed.borrow().clone() { + return r; + } + if self.closed.changed().await.is_err() { + return self.closed.borrow().clone().unwrap_or_else(|| "dropped".into()); + } + } + } + + pub fn rtt(&self) -> u64 { + self.rtt_us.load(Ordering::Relaxed) + } +} + +/// The task side of a link: what its driver holds. +pub struct LinkEnds { + pub data_tx: mpsc::Sender>, + pub send_rx: Option>>, + pub account: Arc>, + pub sent: Arc>, + pub rtt_us: Arc, + pub closed_tx: watch::Sender>, + pub cmd_rx: mpsc::UnboundedReceiver, +} + +/// How many delivered payloads may wait for the application before more are dropped. +pub const DATA_QUEUE: usize = 1 << 16; + +/// A link and its task's ends; `sending` for a link that sends. +pub fn pair(info: LinkInfo, sending: bool) -> (Link, LinkEnds) { + let (data_tx, data) = mpsc::channel(DATA_QUEUE); + let (send, send_rx) = if sending { + let (t, r) = mpsc::channel(4096); + (Some(t), Some(r)) + } else { + (None, None) + }; + let account = Arc::new(Mutex::new(Account::new())); + let sent = Arc::new(Mutex::new(SendCounts::default())); + let rtt_us = Arc::new(AtomicU64::new(0)); + let (closed_tx, closed) = watch::channel(None); + let (cmd, cmd_rx) = mpsc::unbounded_channel(); + ( + Link { info, data, send, account: account.clone(), sent: sent.clone(), rtt_us: rtt_us.clone(), closed, cmd }, + LinkEnds { data_tx, send_rx, account, sent, rtt_us, closed_tx, cmd_rx }, + ) +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/lossy.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/lossy.rs new file mode 100644 index 000000000..71934e721 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/lossy.rs @@ -0,0 +1,176 @@ +//! An impaired path for the tests and the benches: a UDP proxy between one client and one +//! server that drops, delays and reorders datagrams, each direction on its own, from a fixed +//! seed, so a run's losses are the same every time. +//! +//! The client sends to the proxy's address; the proxy forwards to the server from a socket +//! of its own, and forwards what the server sends back to the last address the client sent +//! from. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::UdpSocket; + +/// How one direction is impaired. +#[derive(Clone, Copy, Debug, Default)] +pub struct Impair { + /// Each datagram is dropped with this probability, in parts per million. + pub loss_ppm: u32, + /// Once a datagram is dropped, each of the next `burst - 1` is dropped too. + pub burst: u32, + /// Each datagram is held this long before it goes on. + pub delay_ms: u64, + /// Each datagram is held, with this probability in parts per million, an extra + /// `reorder_ms`, so later ones overtake it. + pub reorder_ppm: u32, + pub reorder_ms: u64, + /// Drop the datagrams whose index in this direction (from 0) is listed. + pub drop_index: &'static [u64], + /// Drop none of the first `spare_first` at random: a RIST receiver cannot know of a + /// packet lost before the first one it receives. + pub spare_first: u64, +} + +/// What the proxy did, per direction. +#[derive(Debug, Default)] +pub struct Counts { + pub forwarded: AtomicU64, + pub dropped: AtomicU64, +} + +/// xorshift64*, from a seed: the same impairment on every run. +#[derive(Clone, Debug)] +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + fn ppm(&mut self, p: u32) -> bool { + p > 0 && (self.next() % 1_000_000) < p as u64 + } +} + +struct Dir { + imp: Impair, + rng: Rng, + index: u64, + burst_left: u32, +} + +impl Dir { + /// Whether to drop the next datagram, and how long to hold it otherwise. + fn decide(&mut self) -> Option { + let i = self.index; + self.index += 1; + if self.imp.drop_index.contains(&i) { + return None; + } + if self.burst_left > 0 { + self.burst_left -= 1; + return None; + } + if i >= self.imp.spare_first && self.rng.ppm(self.imp.loss_ppm) { + self.burst_left = self.imp.burst.saturating_sub(1); + return None; + } + let mut d = self.imp.delay_ms; + if self.rng.ppm(self.imp.reorder_ppm) { + d += self.imp.reorder_ms; + } + Some(Duration::from_millis(d)) + } +} + +/// A running proxy. +pub struct Proxy { + pub addr: SocketAddr, + pub up: Arc, + pub down: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl Proxy { + /// A proxy on loopback forwarding to `server`, impairing client→server datagrams by + /// `up` and server→client ones by `down`, from `seed`. + pub async fn start(server: SocketAddr, up: Impair, down: Impair, seed: u64) -> std::io::Result { + Proxy::start_on("127.0.0.1:0".parse().unwrap(), server, up, down, seed).await + } + + /// As [`Proxy::start`], the proxy's own address `bind`. + pub async fn start_on(bind: SocketAddr, server: SocketAddr, up: Impair, down: Impair, seed: u64) -> std::io::Result { + let front = Arc::new(UdpSocket::bind(bind).await?); + let back = Arc::new(UdpSocket::bind("127.0.0.1:0").await?); + back.connect(server).await?; + let addr = front.local_addr()?; + let (cu, cd) = (Arc::new(Counts::default()), Arc::new(Counts::default())); + let (cu2, cd2) = (cu.clone(), cd.clone()); + let task = tokio::spawn(async move { + let client: Arc>> = Arc::default(); + let mut du = Dir { imp: up, rng: Rng(seed | 1), index: 0, burst_left: 0 }; + let mut dd = Dir { imp: down, rng: Rng(seed.rotate_left(17) | 1), index: 0, burst_left: 0 }; + let mut a = vec![0u8; 65_536]; + let mut b = vec![0u8; 65_536]; + loop { + tokio::select! { + r = front.recv_from(&mut a) => { + let Ok((n, from)) = r else { continue }; + *client.lock().unwrap() = Some(from); + match du.decide() { + None => { cu2.dropped.fetch_add(1, Ordering::Relaxed); } + Some(d) => { + cu2.forwarded.fetch_add(1, Ordering::Relaxed); + let (sock, data) = (back.clone(), a[..n].to_vec()); + if d.is_zero() { let _ = sock.send(&data).await; } else { + tokio::spawn(async move { tokio::time::sleep(d).await; let _ = sock.send(&data).await; }); + } + } + } + } + r = back.recv(&mut b) => { + let Ok(n) = r else { continue }; + let Some(to) = *client.lock().unwrap() else { continue }; + match dd.decide() { + None => { cd2.dropped.fetch_add(1, Ordering::Relaxed); } + Some(d) => { + cd2.forwarded.fetch_add(1, Ordering::Relaxed); + let (sock, data) = (front.clone(), b[..n].to_vec()); + if d.is_zero() { let _ = sock.send_to(&data, to).await; } else { + tokio::spawn(async move { tokio::time::sleep(d).await; let _ = sock.send_to(&data, to).await; }); + } + } + } + } + } + } + }); + Ok(Proxy { addr, up: cu, down: cd, task }) + } + + pub fn stop(self) { + self.task.abort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_same_seed_drops_the_same_datagrams() { + let imp = Impair { loss_ppm: 50_000, burst: 2, ..Impair::default() }; + let run = |seed: u64| { + let mut d = Dir { imp, rng: Rng(seed | 1), index: 0, burst_left: 0 }; + (0..10_000).map(|_| d.decide().is_none()).collect::>() + }; + assert_eq!(run(7), run(7)); + let dropped = run(7).iter().filter(|x| **x).count(); + assert!((600..1400).contains(&dropped), "about 10% with bursts of 2: {dropped}"); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/rist/conn.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/conn.rs new file mode 100644 index 000000000..1bde08f53 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/conn.rs @@ -0,0 +1,521 @@ +//! The RIST receiver and sender, and the tasks that drive them. + +use super::gre::{self, caps, Inner, KeyCache, Writer}; +use super::recv::{RecvConfig, Receiver}; +use super::rtp::{is_rtcp, read_compound, Rtcp, Rtp, PT_MP2T}; +use super::send::{Out, Sender}; +use crate::link::{pair, Cmd, Link, LinkEnds, LinkInfo, Mode, Protocol}; +use std::collections::VecDeque; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Profile { + Simple, + Main, +} + +/// The virtual ports a Main Profile sender's flow uses inside the tunnel: RTP from an even +/// port to an even one, RTCP each one above. +pub const VIRT_SRC: u16 = 1972; +pub const VIRT_DST: u16 = 1968; +const KEEPALIVE_EVERY: Duration = Duration::from_secs(1); +/// What a receiver holds of a flow before it knows the flow is the one it expects. +const PENDING: usize = 4096; +/// The MAC a keep-alive names (§5.6.3): a locally administered address, random per run. +fn mac() -> [u8; 6] { + let r = crate::random_bytes(6); + [0x02, r[1], r[2], r[3], r[4], r[5]] +} + +#[derive(Clone, Debug)] +pub struct RistListenerConfig { + pub profile: Profile, + pub bind_ip: IpAddr, + /// P: even for the Simple Profile (RTCP on P + 1). 0 picks a free one. + pub port: u16, + pub recv: RecvConfig, + /// The Main Profile's PSK passphrase; `None` for a tunnel in the clear. + pub secret: Option, + /// The CNAME the flow must give; `None` takes the first flow. + pub expect_cname: Option, + pub tag: u64, +} + +/// Why a receiver refused a packet that was not of its flow, counted. +#[derive(Debug, Default)] +pub struct Refusals { + pub cname: AtomicU64, + pub undecryptable: AtomicU64, + pub insecure_iv: AtomicU64, + pub malformed: AtomicU64, + pub other_source: AtomicU64, +} + +pub struct RistListener { + pub addr: SocketAddr, + pub incoming: mpsc::Receiver, + pub refused: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for RistListener { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn bind_pair(ip: IpAddr, port: u16) -> std::io::Result<(UdpSocket, UdpSocket)> { + if port != 0 { + if !port.is_multiple_of(2) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the Simple Profile's port P is even (TR-06-1 §5.1.1)")); + } + return Ok((UdpSocket::bind((ip, port)).await?, UdpSocket::bind((ip, port + 1)).await?)); + } + for _ in 0..64 { + let a = UdpSocket::bind((ip, 0)).await?; + let p = a.local_addr()?.port(); + let p = if p.is_multiple_of(2) { p } else { p + 1 }; + if p == 65_534 || p < 1024 { + continue; + } + drop(a); + if let (Ok(a), Ok(b)) = (UdpSocket::bind((ip, p)).await, UdpSocket::bind((ip, p + 1)).await) { + return Ok((a, b)); + } + } + Err(std::io::Error::new(std::io::ErrorKind::AddrInUse, "no free even port pair")) +} + +impl RistListener { + pub async fn bind(cfg: RistListenerConfig) -> std::io::Result { + let (tx, incoming) = mpsc::channel(4); + let refused = Arc::new(Refusals::default()); + let (addr, task) = match cfg.profile { + Profile::Simple => { + let (a, b) = bind_pair(cfg.bind_ip, cfg.port).await?; + let addr = a.local_addr()?; + (addr, tokio::spawn(simple_receiver(a, b, cfg, tx, refused.clone()))) + } + Profile::Main => { + let s = UdpSocket::bind((cfg.bind_ip, cfg.port)).await?; + let addr = s.local_addr()?; + (addr, tokio::spawn(main_receiver(s, cfg, tx, refused.clone()))) + } + }; + Ok(RistListener { addr, incoming, refused, task }) + } + + pub async fn accept(&mut self) -> Option { + self.incoming.recv().await + } +} + +fn new_flow(cfg: &RistListenerConfig, peer: SocketAddr, cname: Option) -> (Link, LinkEnds, Receiver) { + let info = LinkInfo { + protocol: if cfg.profile == Profile::Simple { Protocol::RistSimple } else { Protocol::RistMain }, + mode: Mode::Listener, + peer, + stream_id: None, + cname, + latency_us: cfg.recv.buffer_us, + cipher_bits: 0, + tag: cfg.tag, + }; + let (link, ends) = pair(info, false); + let r = Receiver::new(cfg.recv.clone(), ends.account.clone(), ends.rtt_us.clone()); + (link, ends, r) +} + +/// The SDES CNAME and SSRC of a compound RTCP packet. +fn cname_of(b: &[u8]) -> Option<(u32, String)> { + read_compound(b)?.into_iter().find_map(|p| match p { + Rtcp::Sdes { ssrc, cname: Some(c) } => Some((ssrc, c)), + _ => None, + }) +} + +/// A data packet this receiver accepts: RTP version 2 of payload type 33 whose payload +/// begins with a transport stream's sync byte. +fn data_packet(b: &[u8]) -> Option { + let p = Rtp::read(b)?; + (p.pt == PT_MP2T && p.payload.first() == Some(&crate::ts::SYNC)).then_some(p) +} + +struct Flow { + ip: IpAddr, + rtcp_to: Option, + r: Receiver, + ends: LinkEnds, +} + +impl Flow { + fn flush(&mut self) -> (Vec>, Option) { + for d in std::mem::take(&mut self.r.delivered) { + let _ = self.ends.data_tx.try_send(d); + } + (std::mem::take(&mut self.r.out), self.r.closed.clone()) + } + fn end(self, why: String) { + let _ = self.ends.closed_tx.send(Some(why)); + } +} + +async fn simple_receiver(rtp: UdpSocket, rtcp: UdpSocket, cfg: RistListenerConfig, incoming: mpsc::Sender, refused: Arc) { + let mut pending: VecDeque<(IpAddr, Rtp)> = VecDeque::new(); + let mut flow: Option = None; + let (mut a, mut b) = (vec![0u8; 65_536], vec![0u8; 65_536]); + loop { + let wake = flow.as_ref().map(|f| tokio::time::Instant::from_std(f.r.t0() + Duration::from_micros(f.r.next_wake_us()))); + tokio::select! { + r = rtp.recv_from(&mut a) => { + let Ok((n, from)) = r else { continue }; + let Some(p) = data_packet(&a[..n]) else { refused.malformed.fetch_add(1, Ordering::Relaxed); continue }; + match &mut flow { + Some(f) if f.ip == from.ip() => f.r.on_rtp(p), + Some(_) => { refused.other_source.fetch_add(1, Ordering::Relaxed); } + None => { + pending.push_back((from.ip(), p)); + if pending.len() > PENDING { pending.pop_front(); } + } + } + } + r = rtcp.recv_from(&mut b) => { + let Ok((n, from)) = r else { continue }; + let pkt = &b[..n]; + match &mut flow { + Some(f) if f.ip == from.ip() => { + // RTCP goes back to the address of the last valid RTCP (§5.1.1). + if read_compound(pkt).is_some() { f.rtcp_to = Some(from); } + f.r.on_rtcp(pkt); + } + Some(_) => { refused.other_source.fetch_add(1, Ordering::Relaxed); } + None => { + let Some((ssrc, cname)) = cname_of(pkt) else { continue }; + if cfg.expect_cname.as_ref().is_some_and(|e| *e != cname) { + refused.cname.fetch_add(1, Ordering::Relaxed); + continue; + } + let (link, ends, mut r) = new_flow(&cfg, from, Some(cname)); + r.media = Some(ssrc & !1); + r.on_rtcp(pkt); + for (ip, p) in pending.drain(..) { + if ip == from.ip() && p.ssrc & !1 == ssrc & !1 { r.on_rtp(p); } + } + flow = Some(Flow { ip: from.ip(), rtcp_to: Some(from), r, ends }); + if incoming.send(link).await.is_err() { return; } + } + } + } + _ = async { match wake { Some(w) => tokio::time::sleep_until(w).await, None => std::future::pending().await } } => { + if let Some(f) = &mut flow { f.r.on_timer(); } + } + c = async { match &mut flow { Some(f) => f.ends.cmd_rx.recv().await, None => std::future::pending().await } } => { + if let Some(f) = &mut flow && matches!(c, Some(Cmd::Close) | None) { f.r.close("closed"); } + } + } + if let Some(f) = &mut flow { + let (out, closed) = f.flush(); + if let Some(to) = f.rtcp_to { + for o in out { + let _ = rtcp.send_to(&o, to).await; + } + } + if let Some(why) = closed { + flow.take().unwrap().end(why); + return; + } + } + } +} + +async fn main_receiver(sock: UdpSocket, cfg: RistListenerConfig, incoming: mpsc::Sender, refused: Arc) { + struct Tunnel { + client: SocketAddr, + w: Writer, + flow: Flow, + link: Option, + pending: VecDeque, + /// (our virtual port, theirs) for RTCP. + rtcp_ports: Option<(u16, u16)>, + rtp_ports: Option<(u16, u16)>, + last_keepalive: Instant, + } + let mut cache = KeyCache::default(); + let mac = mac(); + let mut t: Option = None; + let mut buf = vec![0u8; 65_536]; + let mut ka = tokio::time::interval(KEEPALIVE_EVERY); + loop { + let wake = t.as_ref().map(|x| tokio::time::Instant::from_std(x.flow.r.t0() + Duration::from_micros(x.flow.r.next_wake_us()))); + tokio::select! { + r = sock.recv_from(&mut buf) => { + let Ok((n, from)) = r else { continue }; + if t.as_ref().is_some_and(|x| x.client != from) { + refused.other_source.fetch_add(1, Ordering::Relaxed); + continue; + } + let (h, inner) = match gre::read(&buf[..n], cfg.secret.as_deref(), &mut cache) { + Ok(x) => x, + Err(gre::GreError::InsecureIv) => { refused.insecure_iv.fetch_add(1, Ordering::Relaxed); continue } + Err(gre::GreError::Key) => { refused.undecryptable.fetch_add(1, Ordering::Relaxed); continue } + Err(_) => { refused.malformed.fetch_add(1, Ordering::Relaxed); continue } + }; + // A packet that reads after decryption: one of this tunnel's, or the first of + // one. + let valid = match &inner { + Inner::KeepAlive { .. } | Inner::FutureNonce(_) => true, + Inner::Udp { payload, .. } => if is_rtcp(payload) { read_compound(payload).is_some() } else { data_packet(payload).is_some() }, + Inner::Other(_) => t.is_some(), + }; + if !valid { + refused.undecryptable.fetch_add(1, Ordering::Relaxed); + continue; + } + if t.is_none() { + let (link, ends, r) = new_flow(&cfg, from, None); + let vsf = h.proto == gre::ET_VSF; + let mut x = Tunnel { client: from, w: Writer::new(vsf, cfg.secret.clone().map(|s| (s, h.h))), flow: Flow { ip: from.ip(), rtcp_to: None, r, ends }, link: Some(link), pending: VecDeque::new(), rtcp_ports: None, rtp_ports: None, last_keepalive: Instant::now() }; + // The server's keep-alives begin with the client's first message (§5.6.2). + let k = x.w.keepalive(mac, caps::V); + let _ = sock.send_to(&k, from).await; + t = Some(x); + } + let x = t.as_mut().unwrap(); + x.flow.r.alive(); + match inner { + Inner::Udp { src, dst, payload } => { + if is_rtcp(&payload) { + x.rtcp_ports = Some((dst, src)); + x.flow.r.on_rtcp(&payload); + if x.link.is_some() { + let c = x.flow.r.sender_cname.clone(); + match (&cfg.expect_cname, c) { + (Some(e), Some(c)) if *e != c => { + // Not the flow this tunnel is for: let it go. + refused.cname.fetch_add(1, Ordering::Relaxed); + t = None; + continue; + } + (Some(_), None) => {} + (_, c) => { + let mut link = x.link.take().unwrap(); + link.info.cname = c; + link.info.cipher_bits = if cfg.secret.is_some() { if h.h { 256 } else { 128 } } else { 0 }; + for p in x.pending.drain(..) { x.flow.r.on_rtp(p); } + if incoming.send(link).await.is_err() { return; } + } + } + } + } else if let Some(p) = data_packet(&payload) { + x.rtp_ports = Some((dst, src)); + if x.link.is_some() { + x.pending.push_back(p); + if x.pending.len() > PENDING { x.pending.pop_front(); } + } else { + x.flow.r.on_rtp(p); + } + } + } + Inner::KeepAlive { caps: c, .. } if c & caps::D != 0 => x.flow.r.close("peer_disconnect"), + _ => {} + } + } + _ = async { match wake { Some(w) => tokio::time::sleep_until(w).await, None => std::future::pending().await } } => { + if let Some(x) = &mut t { x.flow.r.on_timer(); } + } + _ = ka.tick() => { + if let Some(x) = &mut t && x.last_keepalive.elapsed() >= KEEPALIVE_EVERY { + x.last_keepalive = Instant::now(); + let k = x.w.keepalive(mac, caps::V); + let _ = sock.send_to(&k, x.client).await; + } + } + c = async { match &mut t { Some(x) => x.flow.ends.cmd_rx.recv().await, None => std::future::pending().await } } => { + if let Some(x) = &mut t && matches!(c, Some(Cmd::Close) | None) { x.flow.r.close("closed"); } + } + } + if let Some(x) = &mut t { + let (out, closed) = x.flow.flush(); + let (src, dst) = x.rtcp_ports.or(x.rtp_ports.map(|(a, b)| (a + 1, b + 1))).unwrap_or((VIRT_DST + 1, VIRT_SRC + 1)); + for o in out { + let b = x.w.udp(src, dst, &o); + let _ = sock.send_to(&b, x.client).await; + } + if let Some(why) = closed { + let x = t.take().unwrap(); + x.flow.end(why); + return; + } + } + } +} + +// ── The sender ───────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct SendConfig { + pub profile: Profile, + pub cname: String, + /// The Main Profile's PSK passphrase. + pub secret: Option, + pub aes_256: bool, + pub buffer_ms: u64, + pub idle_ms: u64, + /// The Main Profile's packet formats: TR-06-2:2022's VSF EtherType, or TR-06-2:2021's. + pub vsf: bool, +} + +impl Default for SendConfig { + fn default() -> Self { + SendConfig { profile: Profile::Simple, cname: "wai-contrib".into(), secret: None, aes_256: false, buffer_ms: 1000, idle_ms: 10_000, vsf: true } + } +} + +/// A sending link to the receiver at `remote` (P). +pub async fn send(remote: SocketAddr, cfg: SendConfig) -> std::io::Result { + let any: SocketAddr = if remote.is_ipv4() { "0.0.0.0:0".parse().unwrap() } else { "[::]:0".parse().unwrap() }; + let info = LinkInfo { + protocol: if cfg.profile == Profile::Simple { Protocol::RistSimple } else { Protocol::RistMain }, + mode: Mode::Caller, + peer: remote, + stream_id: None, + cname: Some(cfg.cname.clone()), + latency_us: cfg.buffer_ms * 1000, + cipher_bits: if cfg.secret.is_some() { if cfg.aes_256 { 256 } else { 128 } } else { 0 }, + tag: 0, + }; + let (link, ends) = pair(info, true); + let mut s = Sender::new(cfg.cname.clone(), cfg.buffer_ms * 1000, cfg.idle_ms * 1000, ends.sent.clone(), ends.rtt_us.clone()); + match cfg.profile { + Profile::Simple => { + let rtp = UdpSocket::bind(any).await?; + let rtcp = UdpSocket::bind(any).await?; + s.start(); + tokio::spawn(simple_sender(s, rtp, rtcp, remote, ends)); + } + Profile::Main => { + let sock = UdpSocket::bind(any).await?; + let mut w = Writer::new(cfg.vsf, cfg.secret.clone().map(|p| (p, cfg.aes_256))); + // The client starts with keep-alives, 3 to 10 of them back to back (§5.6.2). + let m = mac(); + for _ in 0..5 { + sock.send_to(&w.keepalive(m, caps::V), remote).await?; + } + s.start(); + tokio::spawn(main_sender(s, sock, w, m, cfg.secret.clone(), remote, ends)); + } + } + Ok(link) +} + +async fn sender_loop(mut s: Sender, mut ends: LinkEnds, mut emit: F, mut recv: R) +where + F: FnMut(Out) -> Fut, + Fut: std::future::Future, + R: FnMut() -> RFut, + RFut: std::future::Future>>, +{ + let reason = loop { + for o in std::mem::take(&mut s.out) { + emit(o).await; + } + if let Some(r) = s.closed.clone() { + break r; + } + let wake = tokio::time::Instant::from_std(s.t0() + Duration::from_micros(s.next_wake_us())); + tokio::select! { + p = async { match &mut ends.send_rx { Some(rx) => rx.recv().await, None => std::future::pending().await } } => match p { + Some(p) => s.send(p), + None => s.close("closed"), + }, + r = recv() => if let Some(b) = r { s.on_rtcp(&b) }, + _ = tokio::time::sleep_until(wake) => s.on_timer(), + c = ends.cmd_rx.recv() => if matches!(c, Some(Cmd::Close) | None) { s.close("closed") }, + } + }; + for o in std::mem::take(&mut s.out) { + emit(o).await; + } + let _ = ends.closed_tx.send(Some(reason)); +} + +async fn simple_sender(s: Sender, rtp: UdpSocket, rtcp: UdpSocket, remote: SocketAddr, ends: LinkEnds) { + let rtp = Arc::new(rtp); + let rtcp = Arc::new(rtcp); + let rtcp_to = SocketAddr::new(remote.ip(), remote.port() + 1); + let (r1, r2) = (rtp.clone(), rtcp.clone()); + let emit = move |o: Out| { + let (r1, r2) = (r1.clone(), r2.clone()); + async move { + let _ = match o { + Out::Rtp(b) => r1.send_to(&b, remote).await, + Out::Rtcp(b) => r2.send_to(&b, rtcp_to).await, + }; + } + }; + let rr = rtcp.clone(); + let recv = move || { + let rr = rr.clone(); + async move { + let mut b = vec![0u8; 2048]; + loop { + match rr.recv_from(&mut b).await { + Ok((n, from)) if from.ip() == remote.ip() => return Some(b[..n].to_vec()), + Ok(_) => continue, + Err(_) => return None, + } + } + } + }; + sender_loop(s, ends, emit, recv).await; +} + +async fn main_sender(s: Sender, sock: UdpSocket, w: Writer, mac: [u8; 6], secret: Option, remote: SocketAddr, ends: LinkEnds) { + let sock = Arc::new(sock); + let w = Arc::new(tokio::sync::Mutex::new((w, Instant::now()))); + let (s1, w1) = (sock.clone(), w.clone()); + let emit = move |o: Out| { + let (s1, w1) = (s1.clone(), w1.clone()); + async move { + let mut g = w1.lock().await; + let (src, dst, b) = match o { + Out::Rtp(b) => (VIRT_SRC, VIRT_DST, b), + Out::Rtcp(b) => (VIRT_SRC + 1, VIRT_DST + 1, b), + }; + let p = g.0.udp(src, dst, &b); + let _ = s1.send_to(&p, remote).await; + // A keep-alive at least every second (§5.6.2). + if g.1.elapsed() >= KEEPALIVE_EVERY { + g.1 = Instant::now(); + let k = g.0.keepalive(mac, caps::V); + let _ = s1.send_to(&k, remote).await; + } + } + }; + let s2 = sock.clone(); + let cache = Arc::new(std::sync::Mutex::new(KeyCache::default())); + let recv = move || { + let (s2, cache, secret) = (s2.clone(), cache.clone(), secret.clone()); + async move { + let mut b = vec![0u8; 2048]; + loop { + let Ok((n, from)) = s2.recv_from(&mut b).await else { return None }; + if from != remote { + continue; + } + let r = gre::read(&b[..n], secret.as_deref(), &mut cache.lock().unwrap()); + if let Ok((_, Inner::Udp { payload, .. })) = r + && is_rtcp(&payload) + { + return Some(payload); + } + } + } + }; + sender_loop(s, ends, emit, recv).await; +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/rist/gre.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/gre.rs new file mode 100644 index 000000000..5eac7f2b3 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/gre.rs @@ -0,0 +1,351 @@ +//! The RIST Main Profile tunnel (VSF TR-06-2:2024 §5, §7): GRE over UDP (RFC 8086) with +//! TR-06-2's use of the GRE header's reserved bits, the VSF EtherType and its packet +//! header, reduced overhead mode, the keep-alive message, and PSK encryption. +//! +//! ```text +//! flags word: C(0x8000) . K(0x2000) S(0x1000) Reserved0 H(0x0040) RV(0x0038) Ver(0x0007) +//! then, each when its bit is set: Checksum + Reserved1, Key (the PSK nonce), Sequence +//! ``` +//! +//! - RV 000 is TR-06-2:2020, 001 TR-06-2:2021, 010 TR-06-2:2022 and later; 011 and 100 are +//! read as 010, 101 to 111 discarded (§5.1). H gives the PSK key length where RV ≥ 001. +//! - The Protocol Type 0xCCE0 is followed by the VSF packet header (§5.2): type 0x0000 +//! (RIST), subtype 0x0000 a reduced overhead packet, 0x8000 a keep-alive, 0x8001 a future +//! nonce announcement. The legacy types 0x88B6 (reduced overhead) and 0x88B5 (keep-alive) +//! have no VSF header (§5.3.2, §5.6.3). +//! - Reduced overhead (§5.3.2): the UDP source and destination ports, then the UDP payload. +//! - PSK (§7.2, §7.3): with K set, the GRE payload (everything after the GRE header) is +//! AES-CTR under PBKDF2-HMAC-SHA256(passphrase, the nonce's four bytes, 1024 iterations), +//! 16 or 32 bytes by H, from the counter block `sequence (4 bytes) || 0^96`. RV 000's +//! counter block put the sequence number in the low bytes, which §7.2 calls insecure; it is +//! refused here. + +use crate::crypto::{ctr_apply, pbkdf2_hmac_sha256}; + +pub const F_C: u16 = 0x8000; +pub const F_K: u16 = 0x2000; +pub const F_S: u16 = 0x1000; +pub const F_H: u16 = 0x0040; +pub const RV_SHIFT: u16 = 3; + +pub const ET_VSF: u16 = 0xcce0; +pub const ET_LEGACY_REDUCED: u16 = 0x88b6; +pub const ET_LEGACY_KEEPALIVE: u16 = 0x88b5; +pub const ET_IPV4: u16 = 0x0800; +pub const ET_EAPOL: u16 = 0x888e; +pub const VSF_RIST: u16 = 0x0000; +pub const SUB_REDUCED: u16 = 0x0000; +pub const SUB_KEEPALIVE: u16 = 0x8000; +pub const SUB_FUTURE_NONCE: u16 = 0x8001; + +pub const PSK_ITER: u32 = 1024; + +/// The keep-alive capability flags (§5.6.3, Figure 8), as a 16-bit word after the MAC. +pub mod caps { + pub const X: u16 = 0x8000; + pub const R: u16 = 0x4000; + pub const B: u16 = 0x2000; + pub const A: u16 = 0x1000; + pub const P: u16 = 0x0800; + pub const E: u16 = 0x0400; + pub const L: u16 = 0x0200; + pub const N: u16 = 0x0100; + pub const D: u16 = 0x0080; + pub const T: u16 = 0x0040; + pub const V: u16 = 0x0020; + pub const J: u16 = 0x0010; + pub const F: u16 = 0x0008; +} + +/// What a tunnel packet carries. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Inner { + /// A UDP payload between virtual ports (reduced overhead mode). + Udp { src: u16, dst: u16, payload: Vec }, + KeepAlive { mac: [u8; 6], caps: u16, json: Vec }, + FutureNonce(u32), + /// A packet this receiver does not process (full datagram mode, EAPOL, an unknown + /// subtype): discarded, though it keeps the tunnel alive (§5.1). + Other(u16), +} + +/// The GRE header's fields. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub rv: u8, + pub h: bool, + pub nonce: Option, + pub seq: Option, + pub proto: u16, +} + +/// Why a tunnel packet is not read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GreError { + Short, + /// RV 101 to 111 (§5.1), or a GRE version other than 0. + UnknownVersion, + /// PSK under RV 000's insecure counter block (§7.2). + InsecureIv, + /// The packet is encrypted and no passphrase is held, or the reverse. + Key, + /// It decrypts to nothing this reader knows. + Malformed, +} + +/// The PSK keys a receiver derives, one per nonce, the last few kept. +#[derive(Default)] +pub struct KeyCache { + keys: Vec<(u32, bool, Vec)>, + pub derived: u64, +} + +impl KeyCache { + pub fn key(&mut self, passphrase: &str, nonce: u32, long: bool) -> &[u8] { + if let Some(i) = self.keys.iter().position(|(n, l, _)| *n == nonce && *l == long) { + return &self.keys[i].2; + } + let k = pbkdf2_hmac_sha256(passphrase.as_bytes(), &nonce.to_be_bytes(), PSK_ITER, if long { 32 } else { 16 }); + self.derived += 1; + if self.keys.len() >= 4 { + self.keys.remove(0); + } + self.keys.push((nonce, long, k)); + &self.keys.last().unwrap().2 + } +} + +fn iv(seq: u32) -> [u8; 16] { + let mut v = [0u8; 16]; + v[..4].copy_from_slice(&seq.to_be_bytes()); + v +} + +/// Read one tunnel datagram: its header, and its inner packet decrypted under `psk` where +/// it is encrypted. +pub fn read(b: &[u8], psk: Option<&str>, cache: &mut KeyCache) -> Result<(Header, Inner), GreError> { + if b.len() < 4 { + return Err(GreError::Short); + } + let f = u16::from_be_bytes([b[0], b[1]]); + let proto = u16::from_be_bytes([b[2], b[3]]); + if f & 0x0007 != 0 { + return Err(GreError::UnknownVersion); + } + let rv = ((f >> RV_SHIFT) & 0x7) as u8; + if rv >= 5 { + return Err(GreError::UnknownVersion); + } + let mut off = 4; + if f & F_C != 0 { + off += 4; + } + let nonce = if f & F_K != 0 { + let n = b.get(off..off + 4).ok_or(GreError::Short)?; + off += 4; + Some(u32::from_be_bytes(n.try_into().unwrap())) + } else { + None + }; + let seq = if f & F_S != 0 { + let n = b.get(off..off + 4).ok_or(GreError::Short)?; + off += 4; + Some(u32::from_be_bytes(n.try_into().unwrap())) + } else { + None + }; + if off > b.len() { + return Err(GreError::Short); + } + let h = Header { rv, h: f & F_H != 0, nonce, seq, proto }; + let mut body = b[off..].to_vec(); + match (nonce.filter(|n| *n != 0), psk) { + (Some(n), Some(p)) => { + if rv == 0 { + return Err(GreError::InsecureIv); + } + let s = seq.ok_or(GreError::Malformed)?; + let key = cache.key(p, n, h.h).to_vec(); + ctr_apply(&key, &iv(s), &mut body); + } + (None, None) => {} + _ => return Err(GreError::Key), + } + let inner = match proto { + ET_VSF => { + if body.len() < 4 { + return Err(GreError::Malformed); + } + let ty = u16::from_be_bytes([body[0], body[1]]); + let sub = u16::from_be_bytes([body[2], body[3]]); + let rest = &body[4..]; + match (ty, sub) { + (VSF_RIST, SUB_REDUCED) => udp(rest)?, + (VSF_RIST, SUB_KEEPALIVE) => keepalive(rest)?, + (VSF_RIST, SUB_FUTURE_NONCE) if rest.len() >= 4 => Inner::FutureNonce(u32::from_be_bytes(rest[..4].try_into().unwrap())), + _ => Inner::Other(sub), + } + } + ET_LEGACY_REDUCED => udp(&body)?, + ET_LEGACY_KEEPALIVE => keepalive(&body)?, + p => Inner::Other(p), + }; + Ok((h, inner)) +} + +fn udp(b: &[u8]) -> Result { + if b.len() < 4 { + return Err(GreError::Malformed); + } + Ok(Inner::Udp { src: u16::from_be_bytes([b[0], b[1]]), dst: u16::from_be_bytes([b[2], b[3]]), payload: b[4..].to_vec() }) +} + +fn keepalive(b: &[u8]) -> Result { + if b.len() < 8 { + return Err(GreError::Malformed); + } + Ok(Inner::KeepAlive { mac: b[..6].try_into().unwrap(), caps: u16::from_be_bytes([b[6], b[7]]), json: b[8..].to_vec() }) +} + +/// How a sender writes its tunnel packets. +#[derive(Clone, Debug)] +pub struct Writer { + /// TR-06-2:2022's VSF EtherType and RV 010; otherwise TR-06-2:2021's legacy types and RV + /// 001. + pub vsf: bool, + /// The passphrase and whether the key is 256 bits. + pub psk: Option<(String, bool)>, + pub nonce: u32, + pub seq: u32, + key: Option>, +} + +impl Writer { + pub fn new(vsf: bool, psk: Option<(String, bool)>) -> Writer { + let nonce = loop { + let n = crate::random_u32(); + if n != 0 { + break n; + } + }; + let key = psk.as_ref().map(|(p, long)| pbkdf2_hmac_sha256(p.as_bytes(), &nonce.to_be_bytes(), PSK_ITER, if *long { 32 } else { 16 })); + Writer { vsf, psk, nonce, seq: crate::random_u32(), key } + } + + fn header(&mut self, proto: u16) -> (Vec, u32) { + let rv: u16 = if self.vsf { 2 } else { 1 }; + let mut f = F_S | (rv << RV_SHIFT); + if self.psk.is_some() { + f |= F_K; + if self.psk.as_ref().is_some_and(|p| p.1) { + f |= F_H; + } + } + let seq = self.seq; + self.seq = self.seq.wrapping_add(1); + // A new nonce whenever the sequence number wraps (§7.2). + if self.seq == 0 + && let Some((p, long)) = &self.psk + { + self.nonce = self.nonce.wrapping_add(1).max(1); + self.key = Some(pbkdf2_hmac_sha256(p.as_bytes(), &self.nonce.to_be_bytes(), PSK_ITER, if *long { 32 } else { 16 })); + } + let mut h = Vec::with_capacity(12); + h.extend_from_slice(&f.to_be_bytes()); + h.extend_from_slice(&proto.to_be_bytes()); + if self.psk.is_some() { + h.extend_from_slice(&self.nonce.to_be_bytes()); + } + h.extend_from_slice(&seq.to_be_bytes()); + (h, seq) + } + + fn seal(&mut self, proto: u16, mut body: Vec) -> Vec { + let (mut h, seq) = self.header(proto); + if let Some(k) = &self.key { + ctr_apply(k, &iv(seq), &mut body); + } + h.extend_from_slice(&body); + h + } + + /// A UDP payload from virtual port `src` to `dst`, in reduced overhead mode. + pub fn udp(&mut self, src: u16, dst: u16, payload: &[u8]) -> Vec { + let mut body = Vec::with_capacity(8 + payload.len()); + if self.vsf { + body.extend_from_slice(&VSF_RIST.to_be_bytes()); + body.extend_from_slice(&SUB_REDUCED.to_be_bytes()); + } + body.extend_from_slice(&src.to_be_bytes()); + body.extend_from_slice(&dst.to_be_bytes()); + body.extend_from_slice(payload); + let proto = if self.vsf { ET_VSF } else { ET_LEGACY_REDUCED }; + self.seal(proto, body) + } + + /// A keep-alive: `mac`, the capability flags, no JSON. + pub fn keepalive(&mut self, mac: [u8; 6], caps: u16) -> Vec { + let mut body = Vec::with_capacity(12); + if self.vsf { + body.extend_from_slice(&VSF_RIST.to_be_bytes()); + body.extend_from_slice(&SUB_KEEPALIVE.to_be_bytes()); + } + body.extend_from_slice(&mac); + body.extend_from_slice(&caps.to_be_bytes()); + let proto = if self.vsf { ET_VSF } else { ET_LEGACY_KEEPALIVE }; + self.seal(proto, body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_overhead_round_trips_clear_and_under_psk() { + for vsf in [true, false] { + for psk in [None, Some(("Reliable Internet Stream Transport".to_string(), false)), Some(("x".repeat(20), true))] { + let mut w = Writer::new(vsf, psk.clone()); + let b = w.udp(1972, 1968, b"\x80\x21hello"); + let mut c = KeyCache::default(); + let (h, i) = read(&b, psk.as_ref().map(|p| p.0.as_str()), &mut c).unwrap(); + assert_eq!(i, Inner::Udp { src: 1972, dst: 1968, payload: b"\x80\x21hello".to_vec() }); + assert_eq!(h.rv, if vsf { 2 } else { 1 }); + assert_eq!(h.proto, if vsf { ET_VSF } else { ET_LEGACY_REDUCED }); + assert_eq!(h.nonce.is_some(), psk.is_some()); + assert_eq!(h.h, psk.as_ref().is_some_and(|p| p.1)); + let ka = w.keepalive([2, 0, 0, 0, 0, 1], caps::V); + assert!(matches!(read(&ka, psk.as_ref().map(|p| p.0.as_str()), &mut c).unwrap().1, Inner::KeepAlive { caps: caps::V, .. })); + } + } + } + + #[test] + fn the_wrong_passphrase_reads_nothing_of_use() { + let mut w = Writer::new(true, Some(("right one".into(), false))); + let b = w.udp(1972, 1968, &[0x80, 0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + let mut c = KeyCache::default(); + if let Ok((_, Inner::Udp { payload, .. })) = read(&b, Some("wrong one"), &mut c) { + assert_ne!(payload[..2], [0x80, 0x21]); + } + assert_eq!(read(&b, None, &mut c).unwrap_err(), GreError::Key); + } + + #[test] + fn rv_000_psk_is_refused() { + let mut w = Writer::new(false, Some(("p".into(), false))); + let mut b = w.udp(1, 2, b"x"); + // Rewrite RV to 000. + let f = u16::from_be_bytes([b[0], b[1]]) & !(0x7 << RV_SHIFT); + b[..2].copy_from_slice(&f.to_be_bytes()); + assert_eq!(read(&b, Some("p"), &mut KeyCache::default()).unwrap_err(), GreError::InsecureIv); + } + + #[test] + fn annex_b_key_and_the_counter_block() { + let mut c = KeyCache::default(); + let k = c.key("Reliable Internet Stream Transport", 0x5249_5354, false).to_vec(); + assert_eq!(hex::encode(k), "1c2b0cfc90ae2638fea78c7fb2977047"); + assert_eq!(iv(0x0102_0304)[..5], [1, 2, 3, 4, 0]); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/rist/mod.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/mod.rs new file mode 100644 index 000000000..b52713f5a --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/mod.rs @@ -0,0 +1,26 @@ +//! RIST: VSF TR-06-1:2020 (Simple Profile) and TR-06-2:2024 (Main Profile). +//! +//! - [`rtp`]: RTP and the RTCP packets the Simple Profile uses. +//! - [`gre`]: the Main Profile's GRE-over-UDP tunnel, reduced overhead mode, keep-alives +//! and PSK. +//! - [`recv`], [`send`]: a flow's receiving and sending sides, without I/O. +//! - [`conn`]: the receiver ([`conn::RistListener`]: for the Simple Profile, RTP on an even +//! port P and RTCP on P+1; for the Main Profile, one tunnel port P) and the sender +//! ([`conn::send`]). +//! +//! A receiver serves one flow: the first whose RTCP CNAME is the one it expects (when it +//! expects one) and, in the Main Profile, whose tunnel packets decrypt under its passphrase. +//! From then on it takes the flow's packets from that address alone. +//! +//! **Not implemented**: bonding (§5.4), SMPTE 2022-1 FEC, multicast, the Main Profile's DTLS +//! (§6), full datagram mode (§5.3.1), EAP-SRP authentication (§7.5), null packet deletion +//! and extended sequence numbers (§8), and on-the-fly passphrase change (§7.4). + +pub mod conn; +pub mod gre; +pub mod recv; +pub mod rtp; +pub mod send; + +pub use conn::{send, Profile, RistListener, RistListenerConfig, SendConfig}; +pub use recv::NackKind; diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/rist/recv.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/recv.rs new file mode 100644 index 000000000..0acae3ca7 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/recv.rs @@ -0,0 +1,397 @@ +//! The receiving side of a RIST flow, without I/O (VSF TR-06-1 §5.3): RTP and RTCP in, +//! RTCP and delivered payloads out. The same machine serves the Simple Profile's two ports +//! and the Main Profile's tunnel. +//! +//! - **The buffer** (§5.3.1): each sequence number is delivered `buffer` after it was +//! expected (its arrival, or, for one found missing, the arrival that showed the gap), +//! in order; one still missing then is dropped. +//! - **Retransmission requests** (§5.3.2): a missing packet is first requested `reorder` +//! after it was found missing, then again every interval: 1.5 × the RTT where the RTT echo +//! (§5.2.6) has measured it, and otherwise (buffer − reorder) / the requests allowed, as +//! Appendix B derives it; at most that many times, and not once its time is too close to +//! be answered. Requests are range requests (§5.3.2.2) or bitmask ones (§5.3.2.1). +//! - **RTCP** (§5.2.1): every 100 ms a compound of an RR (with its report block once data +//! has arrived), an SDES CNAME, and the RTT echo request once a second; the echo +//! requests of the sender are answered (§5.2.6). +//! - **Retransmissions** are the packets whose SSRC has its low bit set (§5.3.3). + +use super::rtp::{compound, ReportBlock, Rtcp, Rtp}; +use crate::seq::Extender; +use crate::stats::{Account, Outcome}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +const RTCP_EVERY_US: u64 = 100_000; +const ECHO_EVERY_US: u64 = 1_000_000; +const MAX_IN_ONE_REQUEST: usize = 16; +const RECENT_DROPS: usize = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NackKind { + Range, + Bitmask, +} + +#[derive(Clone, Debug)] +pub struct RecvConfig { + pub buffer_us: u64, + pub reorder_us: u64, + pub max_requests: u32, + pub nack: NackKind, + pub idle_us: u64, + pub cname: String, +} + +impl Default for RecvConfig { + fn default() -> Self { + // TR-06-1 Appendix B's suggested defaults, but a shorter reorder section for one path. + RecvConfig { buffer_us: 1_000_000, reorder_us: 20_000, max_requests: 7, nack: NackKind::Range, idle_us: 10_000_000, cname: "wai-ingest".into() } + } +} + +struct Slot { + payload: Vec, + arrived_us: u64, + missed_at: Option, +} + +struct Miss { + found_us: u64, + next_req_us: u64, + requests: u32, +} + +pub struct Receiver { + pub cfg: RecvConfig, + t0: Instant, + pub ssrc: u32, + /// The sender's SSRC, its low bit clear. + pub media: Option, + seq_ext: Extender, + first_seq: Option, + next: Option, + highest: u64, + buf: BTreeMap, + missing: BTreeMap, + recent_drops: BTreeSet, + recent_order: VecDeque, + pub account: Arc>, + pub rtt_shared: Arc, + rtt: Option, + last_rtcp_us: u64, + last_echo_us: u64, + last_recv_us: u64, + received: u64, + last_sr: Option<(u32, u64)>, + jitter: f64, + last_transit: Option, + expected_prior: u64, + received_prior: u64, + /// RTCP compound packets to send. + pub out: Vec>, + pub delivered: Vec>, + pub closed: Option, + pub sender_cname: Option, +} + +impl Receiver { + pub fn new(cfg: RecvConfig, account: Arc>, rtt_shared: Arc) -> Receiver { + Receiver { + cfg, + t0: Instant::now(), + ssrc: crate::random_u32() & !1, + media: None, + seq_ext: Extender::new(16), + first_seq: None, + next: None, + highest: 0, + buf: BTreeMap::new(), + missing: BTreeMap::new(), + recent_drops: BTreeSet::new(), + recent_order: VecDeque::new(), + account, + rtt_shared, + rtt: None, + last_rtcp_us: 0, + last_echo_us: 0, + last_recv_us: 0, + received: 0, + last_sr: None, + jitter: 0.0, + last_transit: None, + expected_prior: 0, + received_prior: 0, + out: Vec::new(), + delivered: Vec::new(), + closed: None, + sender_cname: None, + } + } + + pub fn t0(&self) -> Instant { + self.t0 + } + + pub fn now_us(&self) -> u64 { + self.t0.elapsed().as_micros() as u64 + } + + /// Something of the flow arrived that is neither RTP nor RTCP (a tunnel keep-alive): it + /// keeps the flow alive. + pub fn alive(&mut self) { + self.last_recv_us = self.now_us(); + } + + fn interval(&self) -> u64 { + match self.rtt { + Some(r) => (r * 3 / 2).max(10_000), + None => (self.cfg.buffer_us.saturating_sub(self.cfg.reorder_us) / self.cfg.max_requests.max(1) as u64).max(10_000), + } + } + + pub fn on_rtp(&mut self, p: Rtp) { + let now = self.now_us(); + self.last_recv_us = now; + let base = p.ssrc & !1; + match self.media { + None => self.media = Some(base), + Some(m) if m != base => return, + _ => {} + } + if p.ssrc & 1 == 1 { + self.account.lock().unwrap().retransmitted(); + } + let seq = self.seq_ext.extend(p.seq as u64); + // Interarrival jitter (RFC 3550 §6.4.1), 90 kHz, from originals only. + if p.ssrc & 1 == 0 { + let arrival = (now as i64) * 9 / 100; + let transit = arrival - p.ts as i64; + if let Some(lt) = self.last_transit { + let d = (transit - lt).abs() as f64; + self.jitter += (d - self.jitter) / 16.0; + } + self.last_transit = Some(transit); + } + let next = *self.next.get_or_insert(seq); + if self.received == 0 { + self.highest = seq - 1; + self.first_seq = Some(seq); + } + self.received += 1; + if seq < next { + let mut a = self.account.lock().unwrap(); + if self.recent_drops.contains(&seq) { a.late() } else { a.duplicate() } + return; + } + if self.buf.contains_key(&seq) { + self.account.lock().unwrap().duplicate(); + return; + } + let missed_at = self.missing.remove(&seq).map(|m| m.found_us); + if seq > self.highest { + for s in self.highest + 1..seq { + self.missing.insert(s, Miss { found_us: now, next_req_us: now + self.cfg.reorder_us, requests: 0 }); + } + self.highest = seq; + } + self.buf.insert(seq, Slot { payload: p.payload, arrived_us: now, missed_at }); + self.deliver(now); + } + + pub fn on_rtcp(&mut self, b: &[u8]) { + let now = self.now_us(); + self.last_recv_us = now; + let Some(parts) = super::rtp::read_compound(b) else { return }; + let mut reply = Vec::new(); + for p in parts { + match p { + Rtcp::Sdes { cname: Some(c), .. } => self.sender_cname = Some(c), + Rtcp::Sr { ntp, .. } => self.last_sr = Some(((ntp >> 16) as u32, now)), + Rtcp::EchoRequest { media, ts, padding } => reply.push(Rtcp::EchoResponse { media, ts, delay_us: 0, padding }), + Rtcp::EchoResponse { ts, delay_us, .. } => { + let rtt = now.saturating_sub(ts).saturating_sub(delay_us as u64); + if rtt < 10_000_000 { + let r = self.rtt.map_or(rtt, |r| (7 * r + rtt) / 8); + self.rtt = Some(r); + self.rtt_shared.store(r, Ordering::Relaxed); + self.account.lock().unwrap().rtt_sample(rtt as u32); + } + } + _ => {} + } + } + if !reply.is_empty() { + let mut parts = self.report(); + parts.extend(reply); + self.out.push(compound(&parts)); + } + } + + /// The RR and the SDES every compound starts with. + fn report(&mut self) -> Vec { + let block = self.media.filter(|_| self.received > 0).map(|m| { + // RFC 3550 §6.4.1 and §A.3: expected from the first sequence number received. + let expected = (self.highest + 1).saturating_sub(self.first_seq.unwrap_or(self.highest + 1)); + let lost = expected.saturating_sub(self.received.min(expected)); + let de = expected.saturating_sub(self.expected_prior); + let dr = self.received.saturating_sub(self.received_prior); + self.expected_prior = expected; + self.received_prior = self.received; + let fraction = if de == 0 || dr >= de { 0 } else { ((de - dr) * 256 / de).min(255) as u8 }; + let (lsr, dlsr) = match self.last_sr { + Some((l, at)) => (l, ((self.now_us() - at) * 65_536 / 1_000_000) as u32), + None => (0, 0), + }; + ReportBlock { source: m, fraction_lost: fraction, cumulative_lost: lost.min(0x00ff_ffff) as u32, highest: (self.highest & 0xffff_ffff) as u32, jitter: self.jitter as u32, lsr, dlsr } + }); + vec![Rtcp::Rr { ssrc: self.ssrc, block }, Rtcp::Sdes { ssrc: self.ssrc, cname: Some(self.cfg.cname.clone()) }] + } + + fn deliver(&mut self, now: u64) { + let Some(mut next) = self.next else { return }; + loop { + if let Some(s) = self.buf.get(&next) { + let due = s.missed_at.unwrap_or(s.arrived_us) + self.cfg.buffer_us; + if now < due { + break; + } + let s = self.buf.remove(&next).unwrap(); + let bytes = s.payload.len() as u32; + let o = match s.missed_at { + None => Outcome::FirstTry { bytes }, + Some(at) => Outcome::Recovered { bytes, recovery_us: s.arrived_us.saturating_sub(at) as u32, margin_us: due.saturating_sub(s.arrived_us).min(u32::MAX as u64) as u32 }, + }; + self.account.lock().unwrap().resolve(next, o); + self.delivered.push(s.payload); + next += 1; + continue; + } + let Some(m) = self.missing.get(&next) else { break }; + if now < m.found_us + self.cfg.buffer_us { + break; + } + self.missing.remove(&next); + self.account.lock().unwrap().resolve(next, Outcome::Dropped); + self.recent_drops.insert(next); + self.recent_order.push_back(next); + if self.recent_order.len() > RECENT_DROPS + && let Some(old) = self.recent_order.pop_front() + { + self.recent_drops.remove(&old); + } + next += 1; + } + self.next = Some(next); + } + + pub fn on_timer(&mut self) { + let now = self.now_us(); + self.deliver(now); + if self.closed.is_some() { + return; + } + if self.last_recv_us > 0 && now.saturating_sub(self.last_recv_us) > self.cfg.idle_us { + self.close("idle"); + return; + } + // The requests due now. + let interval = self.interval(); + let rtt = self.rtt.unwrap_or(0); + let mut due: Vec = Vec::new(); + let buffer = self.cfg.buffer_us; + let max = self.cfg.max_requests; + for (&s, m) in self.missing.iter_mut() { + let deadline = m.found_us + buffer; + if now >= m.next_req_us && m.requests < max && now + rtt / 2 < deadline { + due.push(s); + m.requests += 1; + m.next_req_us = now + interval; + } + } + let mut parts = Vec::new(); + if !due.is_empty() || now.saturating_sub(self.last_rtcp_us) >= RTCP_EVERY_US { + parts = self.report(); + self.last_rtcp_us = now; + } + if !due.is_empty() + && let Some(m) = self.media + { + match self.cfg.nack { + NackKind::Range => { + let mut ranges: Vec<(u64, u64)> = Vec::new(); + for s in &due { + match ranges.last_mut() { + Some((a, b)) if *b + 1 == *s && *b - *a < 65_535 => *b = *s, + _ => ranges.push((*s, *s)), + } + } + for chunk in ranges.chunks(MAX_IN_ONE_REQUEST) { + let n: u64 = chunk.iter().map(|(a, b)| b - a + 1).sum(); + self.account.lock().unwrap().request(n); + parts.push(Rtcp::Range { media: m, ranges: chunk.iter().map(|(a, b)| (*a as u16, (b - a) as u16)).collect() }); + } + } + NackKind::Bitmask => { + let seqs: Vec = due.iter().map(|s| *s as u16).collect(); + for chunk in seqs.chunks(MAX_IN_ONE_REQUEST * 17) { + self.account.lock().unwrap().request(chunk.len() as u64); + parts.push(Rtcp::Nack { media: m, lost: chunk.to_vec() }); + } + } + } + } + if !parts.is_empty() { + if now.saturating_sub(self.last_echo_us) >= ECHO_EVERY_US + && let Some(m) = self.media + { + self.last_echo_us = now; + parts.push(Rtcp::EchoRequest { media: m, ts: now, padding: Vec::new() }); + } + self.out.push(compound(&parts)); + } + } + + pub fn next_wake_us(&self) -> u64 { + let now = self.now_us(); + let mut w = now + 10_000; + if let Some(n) = self.next { + if let Some(s) = self.buf.get(&n) { + w = w.min(s.missed_at.unwrap_or(s.arrived_us) + self.cfg.buffer_us); + } else if let Some(m) = self.missing.get(&n) { + w = w.min(m.found_us + self.cfg.buffer_us); + } + } + if let Some(m) = self.missing.values().map(|m| m.next_req_us).min() { + w = w.min(m); + } + w.max(now + 200) + } + + pub fn close(&mut self, why: &str) { + if self.closed.is_some() { + return; + } + self.closed = Some(why.into()); + // What is held is delivered now; the missing among it are outstanding. + let Some(mut next) = self.next else { return }; + let mut a = self.account.lock().unwrap(); + while next <= self.highest { + match self.buf.remove(&next) { + Some(s) => { + let bytes = s.payload.len() as u32; + a.resolve(next, match s.missed_at { + None => Outcome::FirstTry { bytes }, + Some(at) => Outcome::Recovered { bytes, recovery_us: s.arrived_us.saturating_sub(at) as u32, margin_us: 0 }, + }); + self.delivered.push(s.payload); + } + None => a.resolve(next, Outcome::Outstanding), + } + next += 1; + } + self.next = Some(next); + self.missing.clear(); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/rist/rtp.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/rtp.rs new file mode 100644 index 000000000..08ee04423 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/rtp.rs @@ -0,0 +1,312 @@ +//! RTP (RFC 3550 §5.1) and the RTCP packets of VSF TR-06-1 §5.2 and §5.3: SR with no +//! report block, RR with none or one, SDES with one CNAME, the RFC 4585 generic NACK +//! (§5.3.2.1), and the APP packets named `RIST`: range retransmission requests (subtype 0, +//! §5.3.2.2) and the RTT echo request and response (subtypes 2 and 3, §5.2.6). + +/// RTP payload type 33, MP2T (RFC 3551). +pub const PT_MP2T: u8 = 33; +pub const RTCP_SR: u8 = 200; +pub const RTCP_RR: u8 = 201; +pub const RTCP_SDES: u8 = 202; +pub const RTCP_APP: u8 = 204; +pub const RTCP_RTPFB: u8 = 205; +pub const RIST_NAME: [u8; 4] = *b"RIST"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rtp { + pub pt: u8, + pub marker: bool, + pub seq: u16, + pub ts: u32, + pub ssrc: u32, + pub payload: Vec, +} + +impl Rtp { + pub fn read(b: &[u8]) -> Option { + if b.len() < 12 || b[0] >> 6 != 2 { + return None; + } + let padding = b[0] & 0x20 != 0; + let ext = b[0] & 0x10 != 0; + let cc = (b[0] & 0x0f) as usize; + let mut off = 12 + 4 * cc; + if ext { + if b.len() < off + 4 { + return None; + } + let words = u16::from_be_bytes([b[off + 2], b[off + 3]]) as usize; + off += 4 + 4 * words; + } + let mut end = b.len(); + if padding { + end = end.checked_sub(*b.last()? as usize)?; + } + if off > end { + return None; + } + Some(Rtp { pt: b[1] & 0x7f, marker: b[1] & 0x80 != 0, seq: u16::from_be_bytes([b[2], b[3]]), ts: u32::from_be_bytes([b[4], b[5], b[6], b[7]]), ssrc: u32::from_be_bytes([b[8], b[9], b[10], b[11]]), payload: b[off..end].to_vec() }) + } + + pub fn write(&self) -> Vec { + let mut o = Vec::with_capacity(12 + self.payload.len()); + o.push(0x80); + o.push((self.marker as u8) << 7 | (self.pt & 0x7f)); + o.extend_from_slice(&self.seq.to_be_bytes()); + o.extend_from_slice(&self.ts.to_be_bytes()); + o.extend_from_slice(&self.ssrc.to_be_bytes()); + o.extend_from_slice(&self.payload); + o + } +} + +/// Whether a datagram is RTCP rather than RTP: its second byte a type of 200 to 206 (RFC +/// 5761 §4). +pub fn is_rtcp(b: &[u8]) -> bool { + b.len() >= 4 && (200..=206).contains(&b[1]) +} + +/// One RTCP packet of a compound one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Rtcp { + Sr { ssrc: u32, ntp: u64, rtp_ts: u32, packets: u32, octets: u32 }, + Rr { ssrc: u32, block: Option }, + Sdes { ssrc: u32, cname: Option }, + /// Generic NACK: the media source, and the lost sequence numbers. + Nack { media: u32, lost: Vec }, + /// A RIST range request: the media source, and (start, additional) pairs. + Range { media: u32, ranges: Vec<(u16, u16)> }, + EchoRequest { media: u32, ts: u64, padding: Vec }, + EchoResponse { media: u32, ts: u64, delay_us: u32, padding: Vec }, + Other { pt: u8 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReportBlock { + pub source: u32, + pub fraction_lost: u8, + pub cumulative_lost: u32, + pub highest: u32, + pub jitter: u32, + pub lsr: u32, + pub dlsr: u32, +} + +fn be32(b: &[u8], i: usize) -> u32 { + u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) +} + +/// Every packet of a compound RTCP datagram; `None` when one's length runs past the end. +pub fn read_compound(b: &[u8]) -> Option> { + let mut out = Vec::new(); + let mut i = 0; + while i + 4 <= b.len() { + if b[i] >> 6 != 2 { + return None; + } + let count = b[i] & 0x1f; + let pt = b[i + 1]; + let len = (u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize + 1) * 4; + if i + len > b.len() { + return None; + } + let p = &b[i..i + len]; + out.push(match pt { + RTCP_SR if p.len() >= 28 => Rtcp::Sr { ssrc: be32(p, 4), ntp: (be32(p, 8) as u64) << 32 | be32(p, 12) as u64, rtp_ts: be32(p, 16), packets: be32(p, 20), octets: be32(p, 24) }, + RTCP_RR if p.len() >= 8 => { + let block = (count >= 1 && p.len() >= 32).then(|| ReportBlock { source: be32(p, 8), fraction_lost: p[12], cumulative_lost: be32(p, 12) & 0x00ff_ffff, highest: be32(p, 16), jitter: be32(p, 20), lsr: be32(p, 24), dlsr: be32(p, 28) }); + Rtcp::Rr { ssrc: be32(p, 4), block } + } + RTCP_SDES if p.len() >= 8 => { + let mut cname = None; + let mut j = 8; + while j + 2 <= p.len() && p[j] != 0 { + let (ty, n) = (p[j], p[j + 1] as usize); + if j + 2 + n > p.len() { + break; + } + if ty == 1 { + cname = Some(String::from_utf8_lossy(&p[j + 2..j + 2 + n]).into_owned()); + } + j += 2 + n; + } + Rtcp::Sdes { ssrc: be32(p, 4), cname } + } + RTCP_RTPFB if count == 1 && p.len() >= 12 => { + let mut lost = Vec::new(); + for f in p[12..].as_chunks::<4>().0 { + let pid = u16::from_be_bytes([f[0], f[1]]); + let blp = u16::from_be_bytes([f[2], f[3]]); + lost.push(pid); + for k in 0..16 { + if blp & (1 << k) != 0 { + lost.push(pid.wrapping_add(k + 1)); + } + } + } + Rtcp::Nack { media: be32(p, 8), lost } + } + RTCP_APP if p.len() >= 12 && p[8..12] == RIST_NAME => match count { + 0 => Rtcp::Range { media: be32(p, 4), ranges: p[12..].as_chunks::<4>().0.iter().map(|f| (u16::from_be_bytes([f[0], f[1]]), u16::from_be_bytes([f[2], f[3]]))).collect() }, + 2 if p.len() >= 24 => Rtcp::EchoRequest { media: be32(p, 4), ts: (be32(p, 12) as u64) << 32 | be32(p, 16) as u64, padding: p[24..].to_vec() }, + 3 if p.len() >= 24 => Rtcp::EchoResponse { media: be32(p, 4), ts: (be32(p, 12) as u64) << 32 | be32(p, 16) as u64, delay_us: be32(p, 20), padding: p[24..].to_vec() }, + _ => Rtcp::Other { pt }, + }, + _ => Rtcp::Other { pt }, + }); + i += len; + } + Some(out) +} + +fn header(count: u8, pt: u8, words_after: usize) -> [u8; 4] { + let len = words_after as u16; + [0x80 | (count & 0x1f), pt, (len >> 8) as u8, len as u8] +} + +impl Rtcp { + pub fn write(&self) -> Vec { + let mut o = Vec::new(); + match self { + Rtcp::Sr { ssrc, ntp, rtp_ts, packets, octets } => { + o.extend_from_slice(&header(0, RTCP_SR, 6)); + for w in [*ssrc, (*ntp >> 32) as u32, *ntp as u32, *rtp_ts, *packets, *octets] { + o.extend_from_slice(&w.to_be_bytes()); + } + } + Rtcp::Rr { ssrc, block } => match block { + None => { + o.extend_from_slice(&header(0, RTCP_RR, 1)); + o.extend_from_slice(&ssrc.to_be_bytes()); + } + Some(b) => { + o.extend_from_slice(&header(1, RTCP_RR, 7)); + o.extend_from_slice(&ssrc.to_be_bytes()); + o.extend_from_slice(&b.source.to_be_bytes()); + o.extend_from_slice(&((b.fraction_lost as u32) << 24 | (b.cumulative_lost & 0x00ff_ffff)).to_be_bytes()); + for w in [b.highest, b.jitter, b.lsr, b.dlsr] { + o.extend_from_slice(&w.to_be_bytes()); + } + } + }, + Rtcp::Sdes { ssrc, cname } => { + let name = cname.as_deref().unwrap_or("").as_bytes(); + let name = &name[..name.len().min(255)]; + let mut body = ssrc.to_be_bytes().to_vec(); + body.push(1); + body.push(name.len() as u8); + body.extend_from_slice(name); + // At least one zero byte ends the chunk, then to a word (§5.2.5). + body.push(0); + while !body.len().is_multiple_of(4) { + body.push(0); + } + o.extend_from_slice(&header(1, RTCP_SDES, body.len() / 4)); + o.extend_from_slice(&body); + } + Rtcp::Nack { media, lost } => { + let fci = nack_fci(lost); + o.extend_from_slice(&header(1, RTCP_RTPFB, 2 + fci.len())); + o.extend_from_slice(&0u32.to_be_bytes()); + o.extend_from_slice(&media.to_be_bytes()); + for (pid, blp) in fci { + o.extend_from_slice(&pid.to_be_bytes()); + o.extend_from_slice(&blp.to_be_bytes()); + } + } + Rtcp::Range { media, ranges } => { + o.extend_from_slice(&header(0, RTCP_APP, 2 + ranges.len())); + o.extend_from_slice(&media.to_be_bytes()); + o.extend_from_slice(&RIST_NAME); + for (s, a) in ranges { + o.extend_from_slice(&s.to_be_bytes()); + o.extend_from_slice(&a.to_be_bytes()); + } + } + Rtcp::EchoRequest { media, ts, padding } | Rtcp::EchoResponse { media, ts, padding, .. } => { + let (sub, delay) = match self { + Rtcp::EchoResponse { delay_us, .. } => (3, *delay_us), + _ => (2, 0), + }; + let pad = &padding[..padding.len() / 4 * 4]; + o.extend_from_slice(&header(sub, RTCP_APP, 5 + pad.len() / 4)); + o.extend_from_slice(&media.to_be_bytes()); + o.extend_from_slice(&RIST_NAME); + o.extend_from_slice(&((*ts >> 32) as u32).to_be_bytes()); + o.extend_from_slice(&(*ts as u32).to_be_bytes()); + o.extend_from_slice(&delay.to_be_bytes()); + o.extend_from_slice(pad); + } + Rtcp::Other { .. } => {} + } + o + } +} + +/// RFC 4585 §6.2.1 FCIs for `lost` (in any order): a PID and the bitmask of the 16 after it. +pub fn nack_fci(lost: &[u16]) -> Vec<(u16, u16)> { + let mut l = lost.to_vec(); + l.sort_unstable(); + l.dedup(); + let mut out: Vec<(u16, u16)> = Vec::new(); + for s in l { + match out.last_mut() { + Some((pid, blp)) if s.wrapping_sub(*pid) >= 1 && s.wrapping_sub(*pid) <= 16 => *blp |= 1 << (s.wrapping_sub(*pid) - 1), + _ => out.push((s, 0)), + } + } + out +} + +/// A compound packet's bytes from its parts. +pub fn compound(parts: &[Rtcp]) -> Vec { + parts.iter().flat_map(|p| p.write()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_tr06_1_appendix_a_requests() { + // 100 lost, 101 and 102 received, 103 to 122 lost. + let lost: Vec = std::iter::once(100).chain(103..=122).collect(); + let fci = nack_fci(&lost); + assert_eq!(fci, vec![(100, 0b1111_1111_1111_1100), (117, 0b0000_0000_0001_1111)]); + let n = Rtcp::Nack { media: 0xaabb_cc00, lost: lost.clone() }.write(); + assert_eq!(&n[..4], &[0x81, 205, 0, 4]); + let back = read_compound(&n).unwrap(); + let Rtcp::Nack { media, lost: mut got } = back[0].clone() else { panic!() }; + got.sort_unstable(); + assert_eq!((media, got), (0xaabb_cc00, lost)); + let r = Rtcp::Range { media: 0xaabb_cc00, ranges: vec![(100, 0), (103, 19)] }.write(); + assert_eq!(&r[..12], &[0x80, 204, 0, 4, 0xaa, 0xbb, 0xcc, 0x00, b'R', b'I', b'S', b'T']); + assert_eq!(&r[12..], &[0, 100, 0, 0, 0, 103, 0, 19]); + } + + #[test] + fn a_compound_round_trips() { + let parts = vec![ + Rtcp::Rr { ssrc: 7, block: Some(ReportBlock { source: 9, fraction_lost: 3, cumulative_lost: 77, highest: 65_600, jitter: 5, lsr: 1, dlsr: 2 }) }, + Rtcp::Sdes { ssrc: 7, cname: Some("wai-ingest".into()) }, + Rtcp::EchoRequest { media: 9, ts: 0x1122_3344_5566_7788, padding: vec![1, 2, 3, 4] }, + Rtcp::EchoResponse { media: 9, ts: 5, delay_us: 40, padding: vec![] }, + Rtcp::Sr { ssrc: 9, ntp: 1 << 40, rtp_ts: 90_000, packets: 10, octets: 13_160 }, + ]; + let b = compound(&parts); + assert_eq!(read_compound(&b).unwrap(), parts); + // The SDES chunk ends in a zero byte and a word boundary. + let s = Rtcp::Sdes { ssrc: 1, cname: Some("abcd".into()) }.write(); + assert_eq!(s.len() % 4, 0); + assert_eq!(&s[8..14], &[1, 4, b'a', b'b', b'c', b'd']); + } + + #[test] + fn rtp_round_trips_and_is_told_from_rtcp() { + let p = Rtp { pt: PT_MP2T, marker: false, seq: 65_535, ts: 9, ssrc: 0x1234_5678, payload: vec![0x47; 1316] }; + let b = p.write(); + assert!(!is_rtcp(&b)); + assert_eq!(Rtp::read(&b).unwrap(), p); + assert!(is_rtcp(&Rtcp::Rr { ssrc: 1, block: None }.write())); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/rist/send.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/send.rs new file mode 100644 index 000000000..77152523b --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/rist/send.rs @@ -0,0 +1,228 @@ +//! The sending side of a RIST flow, without I/O (VSF TR-06-1 §5.2, §5.3): payloads in, +//! RTP out; RTCP requests in, retransmissions out. +//! +//! Each payload is one RTP packet of payload type 33 (MP2T) with a 90 kHz timestamp from +//! the flow's start, kept for `buffer` after it was sent. A bitmask or range request for a +//! packet still held sends it again, its sequence number and timestamp the same and its +//! SSRC's low bit set (§5.3.3). Every 100 ms the sender sends a compound of an SR and an +//! SDES CNAME (§5.2.1, §5.2.2), and the RTT echo request once a second; it answers the +//! receiver's echo requests at once. + +use super::rtp::{compound, Rtcp, Rtp, PT_MP2T}; +use crate::link::SendCounts; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +const RTCP_EVERY_US: u64 = 100_000; +const ECHO_EVERY_US: u64 = 1_000_000; + +/// What the sender emits, by where it goes. +pub enum Out { + Rtp(Vec), + Rtcp(Vec), +} + +pub struct Sender { + t0: Instant, + pub ssrc: u32, + pub cname: String, + buffer_us: u64, + seq: u16, + ts0: u32, + held: BTreeMap)>, + order: std::collections::VecDeque<(u64, u16)>, + packets: u32, + octets: u32, + last_rtcp_us: u64, + last_echo_us: u64, + last_recv_us: u64, + idle_us: u64, + rtt: Option, + pub rtt_shared: Arc, + pub counts: Arc>, + pub out: Vec, + pub closed: Option, +} + +fn ntp_now() -> u64 { + let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = d.as_secs() + 2_208_988_800; + (secs << 32) | (((d.subsec_nanos() as u64) << 32) / 1_000_000_000) +} + +impl Sender { + pub fn new(cname: String, buffer_us: u64, idle_us: u64, counts: Arc>, rtt_shared: Arc) -> Sender { + Sender { + t0: Instant::now(), + ssrc: crate::random_u32() & !1, + cname, + buffer_us, + seq: crate::random_u32() as u16, + ts0: crate::random_u32(), + held: BTreeMap::new(), + order: Default::default(), + packets: 0, + octets: 0, + last_rtcp_us: 0, + last_echo_us: 0, + last_recv_us: 0, + idle_us, + rtt: None, + rtt_shared, + counts, + out: Vec::new(), + closed: None, + } + } + + pub fn t0(&self) -> Instant { + self.t0 + } + + pub fn now_us(&self) -> u64 { + self.t0.elapsed().as_micros() as u64 + } + + fn rtp_ts(&self, now_us: u64) -> u32 { + self.ts0.wrapping_add((now_us * 9 / 100) as u32) + } + + /// The first RTCP, before any media: it opens the path through the firewalls (§5.2). + pub fn start(&mut self) { + let now = self.now_us(); + self.rtcp(now, true); + } + + pub fn send(&mut self, payload: Vec) { + let now = self.now_us(); + let p = Rtp { pt: PT_MP2T, marker: false, seq: self.seq, ts: self.rtp_ts(now), ssrc: self.ssrc, payload }; + let b = p.write(); + self.packets = self.packets.wrapping_add(1); + self.octets = self.octets.wrapping_add(p.payload.len() as u32); + { + let mut c = self.counts.lock().unwrap(); + c.sent += 1; + c.bytes += p.payload.len() as u64; + } + self.out.push(Out::Rtp(b.clone())); + self.held.insert(self.seq, (now, b)); + self.order.push_back((now, self.seq)); + self.seq = self.seq.wrapping_add(1); + self.expire(now); + } + + fn expire(&mut self, now: u64) { + while let Some(&(at, s)) = self.order.front() { + if now.saturating_sub(at) <= self.buffer_us { + break; + } + self.order.pop_front(); + // RIST acknowledges nothing: every packet ages out of the buffer, so none is + // counted as expired. + if self.held.get(&s).is_some_and(|(t, _)| *t == at) { + self.held.remove(&s); + } + } + } + + fn resend(&mut self, s: u16) -> bool { + let Some((_, b)) = self.held.get(&s) else { return false }; + let mut b = b.clone(); + // The same packet, its SSRC's low bit set (§5.3.3). + b[11] |= 1; + self.out.push(Out::Rtp(b)); + true + } + + pub fn on_rtcp(&mut self, b: &[u8]) { + let now = self.now_us(); + self.last_recv_us = now; + let Some(parts) = super::rtp::read_compound(b) else { return }; + let mut reply = Vec::new(); + for p in parts { + match p { + Rtcp::Nack { lost, .. } => { + let mut again = 0; + for s in &lost { + again += self.resend(*s) as u64; + } + let mut c = self.counts.lock().unwrap(); + c.requests += 1; + c.requested += lost.len() as u64; + c.retransmitted += again; + } + Rtcp::Range { ranges, .. } => { + let mut named = 0u64; + let mut again = 0u64; + for (start, extra) in ranges { + for k in 0..=extra as u32 { + named += 1; + again += self.resend(start.wrapping_add(k as u16)) as u64; + } + } + let mut c = self.counts.lock().unwrap(); + c.requests += 1; + c.requested += named; + c.retransmitted += again; + } + Rtcp::EchoRequest { media, ts, padding } => reply.push(Rtcp::EchoResponse { media, ts, delay_us: 0, padding }), + Rtcp::EchoResponse { ts, delay_us, .. } => { + let rtt = now.saturating_sub(ts).saturating_sub(delay_us as u64); + if rtt < 10_000_000 { + let r = self.rtt.map_or(rtt, |r| (7 * r + rtt) / 8); + self.rtt = Some(r); + self.rtt_shared.store(r, Ordering::Relaxed); + } + } + _ => {} + } + } + if !reply.is_empty() { + let mut parts = self.report(now); + parts.extend(reply); + self.out.push(Out::Rtcp(compound(&parts))); + } + } + + fn report(&self, now: u64) -> Vec { + vec![Rtcp::Sr { ssrc: self.ssrc, ntp: ntp_now(), rtp_ts: self.rtp_ts(now), packets: self.packets, octets: self.octets }, Rtcp::Sdes { ssrc: self.ssrc, cname: Some(self.cname.clone()) }] + } + + fn rtcp(&mut self, now: u64, echo: bool) { + let mut parts = self.report(now); + if echo { + self.last_echo_us = now; + parts.push(Rtcp::EchoRequest { media: self.ssrc, ts: now, padding: Vec::new() }); + } + self.out.push(Out::Rtcp(compound(&parts))); + self.last_rtcp_us = now; + } + + pub fn on_timer(&mut self) { + let now = self.now_us(); + if self.closed.is_some() { + return; + } + if self.last_recv_us > 0 && now.saturating_sub(self.last_recv_us) > self.idle_us { + self.closed = Some("idle".into()); + return; + } + self.expire(now); + if now.saturating_sub(self.last_rtcp_us) >= RTCP_EVERY_US { + let echo = now.saturating_sub(self.last_echo_us) >= ECHO_EVERY_US; + self.rtcp(now, echo); + } + } + + pub fn next_wake_us(&self) -> u64 { + self.now_us() + 20_000 + } + + pub fn close(&mut self, why: &str) { + if self.closed.is_none() { + self.closed = Some(why.into()); + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/seq.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/seq.rs new file mode 100644 index 000000000..cb03d6585 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/seq.rs @@ -0,0 +1,101 @@ +//! Sequence numbers and timestamps that wrap: SRT's 31-bit packet sequence numbers and +//! 32-bit microsecond timestamps, RTP's 16-bit sequence numbers, MPEG-TS's 33-bit PTS. +//! Each is extended past its width into a u64 by choosing, for every value read, the +//! extension nearest the last one, as RFC 3550 §A.1 extends RTP's sequence numbers. + +/// Extends values of `bits` bits that wrap into a u64 sequence. The first value read is +/// placed at `base` wraps above zero so that a value read just before it (a reordered +/// packet) can still be placed below it. +#[derive(Clone, Debug)] +pub struct Extender { + bits: u32, + last: Option, +} + +impl Extender { + pub fn new(bits: u32) -> Extender { + assert!(bits > 1 && bits < 63); + Extender { bits, last: None } + } + + fn modulus(&self) -> u64 { + 1u64 << self.bits + } + + /// The extended value of `v`, the one nearest the last value read (a value exactly half + /// the range away is placed after it). The last value is the highest extended value + /// read so far. + pub fn extend(&mut self, v: u64) -> u64 { + let m = self.modulus(); + let v = v & (m - 1); + let e = match self.last { + // One wrap above zero, so a value read before the first can lie below it. + None => m + v, + Some(last) => { + // `last` is at least `m`, so the wrap below its own is never negative. + let base = last & !(m - 1); + let mut best = base + v; + for c in [base - m + v, base + m + v] { + let (dc, db) = (c.abs_diff(last), best.abs_diff(last)); + // A tie goes to the later value. + if dc < db || (dc == db && c > best) { + best = c; + } + } + best + } + }; + self.last = Some(self.last.map_or(e, |l| l.max(e))); + e + } + + /// The extended value `v` would get, without recording it. + pub fn peek(&self, v: u64) -> u64 { + self.clone().extend(v) + } + + pub fn last(&self) -> Option { + self.last + } +} + +/// `a` − `b` for values of `bits` bits that wrap, as a signed distance in (−2^(bits−1), +/// 2^(bits−1)]. +pub fn wrapping_diff(a: u64, b: u64, bits: u32) -> i64 { + let m = 1u64 << bits; + let d = (a.wrapping_sub(b)) & (m - 1); + if d > m / 2 { d as i64 - m as i64 } else { d as i64 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rtp_sequence_numbers_extend_across_the_wrap() { + let mut e = Extender::new(16); + let a = e.extend(65_534); + let b = e.extend(65_535); + let c = e.extend(0); + let d = e.extend(1); + assert_eq!((b - a, c - b, d - c), (1, 1, 1)); + // A packet reordered from before the wrap lands below. + assert_eq!(e.extend(65_533), a - 1); + assert_eq!(e.last(), Some(d)); + } + + #[test] + fn a_value_read_before_the_first_lies_below_it() { + let mut e = Extender::new(31); + let first = e.extend(10); + assert_eq!(e.extend(9), first - 1); + assert_eq!(e.extend((1u64 << 31) - 1), first - 11); + } + + #[test] + fn diffs_are_signed() { + assert_eq!(wrapping_diff(1, 65_535, 16), 2); + assert_eq!(wrapping_diff(65_535, 1, 16), -2); + assert_eq!(wrapping_diff(5, 5, 31), 0); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/srt/conn.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/conn.rs new file mode 100644 index 000000000..da294f9c3 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/conn.rs @@ -0,0 +1,611 @@ +//! The SRT listener and caller, and the task that drives each connection. + +use super::crypt::{KmError, Keys}; +use super::packet::{ctrl, ext, flags, hs, rej, sid_read, sid_write, Control, Ext, Handshake, HsReq, KeyMaterial, Packet}; +use super::recv::{RecvConfig, Receiver}; +use super::send::Sender; +use super::SRT_VERSION; +use crate::link::{pair, Cmd, Link, LinkEnds, LinkInfo, Mode, Protocol}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +/// Whether the local end of a connection receives the stream or sends it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Receive, + Send, +} + +/// What a caller asks for, as the listener's admission sees it. +#[derive(Clone, Debug)] +pub struct ConnRequest { + pub peer: SocketAddr, + pub stream_id: Option, + /// The caller sent key material (it means to encrypt), of this key length in bytes. + pub keylen: Option, + /// The caller's proposed latency, ms: the larger of its two TSBPD delays. + pub latency_ms: u16, +} + +/// What the admission gives a connection it accepts. +#[derive(Clone, Debug)] +pub struct Admitted { + /// The passphrase the connection is encrypted under; `None` for one in the clear. + pub passphrase: Option, + pub role: Role, + /// The listener's latency for this connection, ms; `None` for its configured one. + pub latency_ms: Option, + /// The application's own handle, handed back on the link. + pub tag: u64, +} + +/// The application's decision on each caller: admit it, or refuse it with a reason code +/// (`packet::rej`). +pub trait Admission: Send + Sync + 'static { + fn admit(&self, r: &ConnRequest) -> Result; +} + +impl Admission for F +where + F: Fn(&ConnRequest) -> Result + Send + Sync + 'static, +{ + fn admit(&self, r: &ConnRequest) -> Result { + self(r) + } +} + +#[derive(Clone, Debug)] +pub struct ListenerConfig { + pub bind: SocketAddr, + /// The listener's latency, ms (§4.4: the connection's is the larger of the two ends'). + pub latency_ms: u16, + /// A connection that receives nothing for this long is ended. + pub peer_idle_ms: u64, + pub mtu: u32, + pub flow_window: u32, + pub max_connections: usize, + /// The key length the INDUCTION reply advertises, bytes; 0 advertises none. + pub advertise_keylen: usize, +} + +impl Default for ListenerConfig { + fn default() -> Self { + ListenerConfig { bind: "127.0.0.1:0".parse().unwrap(), latency_ms: 120, peer_idle_ms: 5_000, mtu: 1500, flow_window: 8192, max_connections: 64, advertise_keylen: 0 } + } +} + +/// The Encryption Field of a key length (Table 2). +pub fn enc_field(keylen: usize) -> u16 { + match keylen { + 16 => 2, + 24 => 3, + 32 => 4, + _ => 0, + } +} + +fn random_id() -> u32 { + (crate::random_u32() & 0x3fff_ffff).max(1) +} + +fn random_isn() -> u32 { + crate::random_u32() & 0x7fff_ffff +} + +/// A running listener. +pub struct Listener { + pub addr: SocketAddr, + pub incoming: mpsc::Receiver, + /// Callers refused, by any reason. + pub refused: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl Listener { + pub async fn bind(cfg: ListenerConfig, admission: Arc) -> std::io::Result { + let sock = Arc::new(UdpSocket::bind(cfg.bind).await?); + let addr = sock.local_addr()?; + let (tx, incoming) = mpsc::channel(64); + let refused = Arc::new(AtomicU64::new(0)); + let task = tokio::spawn(listen(sock, cfg, admission, tx, refused.clone())); + Ok(Listener { addr, incoming, refused, task }) + } + + pub async fn accept(&mut self) -> Option { + self.incoming.recv().await + } + + pub fn stop(&self) { + self.task.abort(); + } +} + +impl Drop for Listener { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// The SYN cookie (§4.3.1.1): from the caller's address and port and the minute, under the +/// listener's secret. +fn cookie(secret: &[u8; 32], peer: SocketAddr, minute: u64) -> u32 { + let mut h = Sha256::new(); + h.update(secret); + h.update(peer.ip().to_string().as_bytes()); + h.update(peer.port().to_be_bytes()); + h.update(minute.to_be_bytes()); + let d = h.finalize(); + u32::from_be_bytes([d[0], d[1], d[2], d[3]]) +} + +fn minute() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() / 60) +} + +struct Accepted { + our_id: u32, + peer: SocketAddr, + reply: Vec, + tx: mpsc::Sender>, +} + +#[allow(clippy::too_many_arguments)] +fn reply_to(hs_in: &Handshake, from: SocketAddr, hs_type: u32, socket_id: u32, cookie: u32, enc: u16, ext_field: u16, exts: Vec, mtu: u32, fw: u32) -> Handshake { + Handshake { version: 5, enc_field: enc, ext_field, isn: hs_in.isn, mtu: hs_in.mtu.min(mtu), flow_window: hs_in.flow_window.min(fw), hs_type, socket_id, cookie, peer_ip: Handshake::ip_field(from.ip()), exts } +} + +async fn listen(sock: Arc, cfg: ListenerConfig, admission: Arc, incoming: mpsc::Sender, refused: Arc) { + let secret: [u8; 32] = crate::random_bytes(32).try_into().unwrap(); + let listener_id = random_id(); + let started = Instant::now(); + // Connections by the socket ID their packets name; and the CONCLUSION each answered, + // by (caller, caller's socket ID), sent again if the caller repeats it. + let mut by_id: HashMap = HashMap::new(); + let mut by_caller: HashMap<(SocketAddr, u32), u32> = HashMap::new(); + let (done_tx, mut done_rx) = mpsc::unbounded_channel::(); + let mut buf = vec![0u8; 65_536]; + loop { + let (n, from) = tokio::select! { + r = sock.recv_from(&mut buf) => match r { Ok(x) => x, Err(_) => continue }, + Some(id) = done_rx.recv() => { + if let Some(a) = by_id.remove(&id) { + by_caller.retain(|_, v| *v != a.our_id); + } + continue; + } + }; + let b = &buf[..n]; + let Some(dst) = Packet::dst_of(b) else { continue }; + if dst != 0 && dst != listener_id { + if let Some(a) = by_id.get(&dst) + && a.peer == from + { + let _ = a.tx.try_send(b.to_vec()); + } + continue; + } + let Some(Packet::Control(c)) = Packet::read(b) else { continue }; + if c.ctype != ctrl::HANDSHAKE { + continue; + } + let Some(h) = Handshake::read(&c.cif) else { continue }; + let send_hs = |hs: &Handshake, dst: u32, ts: u32| Control::new(ctrl::HANDSHAKE, 0, ts, dst, hs.write()).write(); + let now_ts = started.elapsed().as_micros() as u32; + match h.hs_type { + hs::INDUCTION => { + let ck = cookie(&secret, from, minute()); + let r = reply_to(&h, from, hs::INDUCTION, listener_id, ck, enc_field(cfg.advertise_keylen), hs::MAGIC, Vec::new(), cfg.mtu, cfg.flow_window); + let _ = sock.send_to(&send_hs(&r, h.socket_id, now_ts), from).await; + } + hs::CONCLUSION => { + if let Some(id) = by_caller.get(&(from, h.socket_id)) + && let Some(a) = by_id.get(id) + { + let _ = sock.send_to(&a.reply, from).await; + continue; + } + let m = minute(); + if h.cookie != cookie(&secret, from, m) && h.cookie != cookie(&secret, from, m.saturating_sub(1)) { + continue; + } + let reject = |code: u32| { + let r = reply_to(&h, from, hs::FAILURE_BASE + code, listener_id, 0, 0, 0, Vec::new(), cfg.mtu, cfg.flow_window); + send_hs(&r, h.socket_id, now_ts) + }; + match conclude(&h, from, &cfg, admission.as_ref(), by_id.len()) { + Err(code) => { + refused.fetch_add(1, Ordering::Relaxed); + let _ = sock.send_to(&reject(code), from).await; + } + Ok(ok) => { + let our_id = loop { + let id = random_id(); + if !by_id.contains_key(&id) && id != listener_id { + break id; + } + }; + let reply = reply_to(&h, from, hs::CONCLUSION, our_id, 0, ok.enc, ok.ext_field, ok.exts.clone(), cfg.mtu, cfg.flow_window); + // The reply is stamped 0 on the new connection's clock, which its + // data packets' timestamps also count from. + let reply = send_hs(&reply, h.socket_id, 0); + let t0 = Instant::now(); + let (dtx, drx) = mpsc::channel(4096); + let info = LinkInfo { protocol: Protocol::Srt, mode: Mode::Listener, peer: from, stream_id: ok.stream_id.clone(), cname: None, latency_us: ok.latency_ms as u64 * 1000, cipher_bits: (ok.keys.as_ref().map_or(0, |k| k.klen) * 8) as u16, tag: ok.tag }; + let (link, ends) = pair(info, ok.role == Role::Send); + let machine = match ok.role { + Role::Receive => Machine::Recv(Box::new(Receiver::new( + RecvConfig { latency_us: ok.latency_ms as u64 * 1000, peer_idle_us: cfg.peer_idle_ms * 1000, flow_window: cfg.flow_window, require_crypto: ok.keys.is_some(), keys: ok.keys.clone(), passphrase: ok.passphrase.clone() }, + h.socket_id, + h.isn, + c.ts, + t0, + ends.account.clone(), + ends.rtt_us.clone(), + ))), + Role::Send => Machine::Send(Box::new(Sender::new(h.socket_id, h.isn, ok.latency_ms as u64 * 1000, cfg.peer_idle_ms * 1000, ok.keys.clone(), t0, ends.sent.clone(), ends.rtt_us.clone()))), + }; + let _ = sock.send_to(&reply, from).await; + by_caller.insert((from, h.socket_id), our_id); + by_id.insert(our_id, Accepted { our_id, peer: from, reply, tx: dtx }); + let done = done_tx.clone(); + tokio::spawn(run(machine, sock.clone(), from, Source::Chan(drx), ends, Some((done, our_id)))); + if incoming.send(link).await.is_err() { + return; + } + } + } + } + _ => {} + } + } +} + +struct Concluded { + stream_id: Option, + keys: Option, + passphrase: Option, + role: Role, + latency_ms: u16, + tag: u64, + enc: u16, + ext_field: u16, + exts: Vec, +} + +/// The listener's checks on a CONCLUSION, in order: its version and HSREQ, the stream ID, +/// the admission, then the keys. +fn conclude(h: &Handshake, from: SocketAddr, cfg: &ListenerConfig, admission: &dyn Admission, held: usize) -> Result { + if h.version != 5 { + return Err(8); // REJ_VERSION: an HSv4 (UDT) caller + } + let req = h.ext(ext::HSREQ).and_then(|e| HsReq::read(&e.data)).ok_or(rej::ROGUE)?; + if req.flags & flags::STREAM != 0 { + return Err(rej::MESSAGEAPI); + } + let stream_id = match h.ext(ext::SID) { + Some(e) => Some(sid_read(&e.data).ok_or(rej::ROGUE)?), + None => None, + }; + let km = match h.ext(ext::KMREQ) { + Some(e) => Some((KeyMaterial::read(&e.data).ok_or(rej::ROGUE)?, e.data.clone())), + None => None, + }; + if held >= cfg.max_connections { + return Err(rej::OVERLOAD); + } + let r = ConnRequest { peer: from, stream_id: stream_id.clone(), keylen: km.as_ref().map(|k| k.0.klen), latency_ms: req.recv_delay_ms.max(req.send_delay_ms) }; + let ok = admission.admit(&r)?; + let keys = match (&ok.passphrase, &km) { + (Some(p), Some((k, _))) => Some(Keys::from_km(k, p).map_err(|e| match e { + KmError::BadSecret => rej::BADSECRET, + KmError::Unsupported => rej::UNSECURE, + })?), + (None, None) => None, + // A passphrase the caller lacks, or one the listener does not have (§6.1.5; + // encryption is enforced both ways). + _ => return Err(rej::UNSECURE), + }; + let ours = ok.latency_ms.unwrap_or(cfg.latency_ms); + // The caller's receive delay governs what the listener sends, its send delay what the + // listener receives (§4.4: the larger of the two ends'). + let rcv = ours.max(req.send_delay_ms); + let snd = ours.max(req.recv_delay_ms); + let latency_ms = if ok.role == Role::Receive { rcv } else { snd }; + let mut exts = vec![Ext { ty: ext::HSRSP, data: HsReq { version: SRT_VERSION, flags: flags::LIVE, recv_delay_ms: rcv, send_delay_ms: snd }.write() }]; + let mut ext_field = ext::F_HSREQ; + let mut enc = 0; + if let Some((k, raw)) = &km { + exts.push(Ext { ty: ext::KMRSP, data: raw.clone() }); + ext_field |= ext::F_KMREQ; + enc = enc_field(k.klen); + } + Ok(Concluded { stream_id, keys, passphrase: ok.passphrase, role: ok.role, latency_ms, tag: ok.tag, enc, ext_field, exts }) +} + +// ── The caller ───────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct CallConfig { + pub latency_ms: u16, + pub passphrase: Option, + /// The key length, bytes: 16, 24 or 32. + pub keylen: usize, + pub stream_id: Option, + pub peer_idle_ms: u64, + pub connect_timeout_ms: u64, + pub mtu: u32, + pub flow_window: u32, + pub role: Role, + /// The local address to call from; unspecified, any. + pub bind: Option, +} + +impl Default for CallConfig { + fn default() -> Self { + CallConfig { latency_ms: 120, passphrase: None, keylen: 16, stream_id: None, peer_idle_ms: 5_000, connect_timeout_ms: 3_000, mtu: 1500, flow_window: 8192, role: Role::Send, bind: None } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CallError { + Io(String), + Timeout, + /// The listener refused, with this reason code. + Rejected(u32), + /// The listener answered key material with a state, not the keys (a wrong or missing + /// passphrase on its side). + KeyMaterial(u32), + /// The listener's answer does not read. + Rogue, +} + +impl std::fmt::Display for CallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CallError::Io(e) => write!(f, "{e}"), + CallError::Timeout => write!(f, "the listener did not answer in time"), + CallError::Rejected(c) => write!(f, "rejected with reason {c}"), + CallError::KeyMaterial(s) => write!(f, "key material refused, state {s}"), + CallError::Rogue => write!(f, "the listener's answer does not read"), + } + } +} + +/// Call the listener at `remote`, and return the link once the handshake completes. +pub async fn call(remote: SocketAddr, cfg: CallConfig) -> Result { + let bind = cfg.bind.unwrap_or_else(|| if remote.is_ipv4() { "0.0.0.0:0".parse().unwrap() } else { "[::]:0".parse().unwrap() }); + let sock = Arc::new(UdpSocket::bind(bind).await.map_err(|e| CallError::Io(e.to_string()))?); + let our_id = random_id(); + let isn = random_isn(); + let t0 = Instant::now(); + let deadline = t0 + Duration::from_millis(cfg.connect_timeout_ms); + let ts = || t0.elapsed().as_micros() as u32; + let base = Handshake { version: 4, enc_field: 0, ext_field: hs::UDT_DGRAM, isn, mtu: cfg.mtu, flow_window: cfg.flow_window, hs_type: hs::INDUCTION, socket_id: our_id, cookie: 0, peer_ip: Handshake::ip_field(remote.ip()), exts: Vec::new() }; + let induction = Control::new(ctrl::HANDSHAKE, 0, 0, 0, base.write()); + + let mut buf = vec![0u8; 65_536]; + // Each request is sent again every 250 ms until it is answered. + async fn exchange(sock: &UdpSocket, remote: SocketAddr, req: impl Fn() -> Vec, deadline: Instant, buf: &mut [u8], want: impl Fn(&Handshake) -> bool) -> Result<(Handshake, u32), CallError> { + loop { + if Instant::now() >= deadline { + return Err(CallError::Timeout); + } + sock.send_to(&req(), remote).await.map_err(|e| CallError::Io(e.to_string()))?; + let until = (Instant::now() + Duration::from_millis(250)).min(deadline); + while Instant::now() < until { + let r = tokio::time::timeout_at(tokio::time::Instant::from_std(until), sock.recv_from(buf)).await; + let Ok(Ok((n, from))) = r else { break }; + if from != remote { + continue; + } + if let Some(Packet::Control(c)) = Packet::read(&buf[..n]) + && c.ctype == ctrl::HANDSHAKE + && let Some(h) = Handshake::read(&c.cif) + && want(&h) + { + return Ok((h, c.ts)); + } + } + } + } + + let (ind, _) = exchange(&sock, remote, || Control { ts: ts(), ..induction.clone() }.write(), deadline, &mut buf, |h| h.hs_type == hs::INDUCTION || (hs::FAILURE_BASE..hs::DONE).contains(&h.hs_type)).await?; + if (hs::FAILURE_BASE..hs::DONE).contains(&ind.hs_type) { + return Err(CallError::Rejected(ind.hs_type - hs::FAILURE_BASE)); + } + if ind.version != 5 || ind.ext_field != hs::MAGIC { + return Err(CallError::Rogue); + } + + // The CONCLUSION: HSREQ, the key material, the stream ID. + let mut exts = vec![Ext { ty: ext::HSREQ, data: HsReq { version: SRT_VERSION, flags: flags::LIVE, recv_delay_ms: cfg.latency_ms, send_delay_ms: cfg.latency_ms }.write() }]; + let mut ext_field = ext::F_HSREQ; + let mut keys = None; + let mut km_sent = None; + if let Some(p) = &cfg.passphrase { + let (k, km) = Keys::generate(p, cfg.keylen); + let raw = km.write(); + exts.push(Ext { ty: ext::KMREQ, data: raw.clone() }); + ext_field |= ext::F_KMREQ; + keys = Some(k); + km_sent = Some(raw); + } + if let Some(s) = &cfg.stream_id { + exts.push(Ext { ty: ext::SID, data: sid_write(s) }); + ext_field |= ext::F_CONFIG; + } + let conclusion = Handshake { + version: 5, + enc_field: if cfg.passphrase.is_some() { enc_field(cfg.keylen) } else { 0 }, + ext_field, + hs_type: hs::CONCLUSION, + cookie: ind.cookie, + exts, + ..base.clone() + }; + let concl = Control::new(ctrl::HANDSHAKE, 0, 0, 0, conclusion.write()); + let (rsp, rsp_ts) = exchange(&sock, remote, || Control { ts: ts(), ..concl.clone() }.write(), deadline, &mut buf, |h| h.hs_type == hs::CONCLUSION || (hs::FAILURE_BASE..hs::DONE).contains(&h.hs_type)).await?; + if (hs::FAILURE_BASE..hs::DONE).contains(&rsp.hs_type) { + return Err(CallError::Rejected(rsp.hs_type - hs::FAILURE_BASE)); + } + let hsrsp = rsp.ext(ext::HSRSP).and_then(|e| HsReq::read(&e.data)).ok_or(CallError::Rogue)?; + if let Some(raw) = &km_sent { + match rsp.ext(ext::KMRSP) { + Some(e) if e.data == *raw => {} + Some(e) if e.data.len() == 4 => return Err(CallError::KeyMaterial(u32::from_be_bytes(e.data[..4].try_into().unwrap()))), + _ => return Err(CallError::Rogue), + } + } + let peer_id = rsp.socket_id; + // The connection's latency each way is the larger of the two ends' (§4.4). + let rcv = cfg.latency_ms.max(hsrsp.send_delay_ms); + let snd = cfg.latency_ms.max(hsrsp.recv_delay_ms); + let latency_ms = if cfg.role == Role::Receive { rcv } else { snd }; + let info = LinkInfo { protocol: Protocol::Srt, mode: Mode::Caller, peer: remote, stream_id: cfg.stream_id.clone(), cname: None, latency_us: latency_ms as u64 * 1000, cipher_bits: keys.as_ref().map_or(0, |k: &Keys| k.klen as u16 * 8), tag: 0 }; + let (link, ends) = pair(info, cfg.role == Role::Send); + let machine = match cfg.role { + Role::Receive => { + // The listener's data is stamped on the clock its CONCLUSION reply was. + let r = Receiver::new( + RecvConfig { latency_us: rcv as u64 * 1000, peer_idle_us: cfg.peer_idle_ms * 1000, flow_window: cfg.flow_window, require_crypto: keys.is_some(), keys: keys.clone(), passphrase: cfg.passphrase.clone() }, + peer_id, + isn, + rsp_ts, + Instant::now(), + ends.account.clone(), + ends.rtt_us.clone(), + ); + Machine::Recv(Box::new(r)) + } + Role::Send => Machine::Send(Box::new(Sender::new(peer_id, isn, snd as u64 * 1000, cfg.peer_idle_ms * 1000, keys, t0, ends.sent.clone(), ends.rtt_us.clone()))), + }; + tokio::spawn(run(machine, sock, remote, Source::Socket, ends, None)); + Ok(link) +} + +// ── The connection task ──────────────────────────────────────────────────────────── + +pub(crate) enum Machine { + Recv(Box), + Send(Box), +} + +impl Machine { + fn t0(&self) -> Instant { + match self { + Machine::Recv(r) => r.t0(), + Machine::Send(s) => s.t0(), + } + } + fn on_datagram(&mut self, b: &[u8]) { + match self { + Machine::Recv(r) => r.on_datagram(b), + Machine::Send(s) => s.on_datagram(b), + } + } + fn on_timer(&mut self) { + match self { + Machine::Recv(r) => r.on_timer(), + Machine::Send(s) => s.on_timer(), + } + } + fn next_wake_us(&self) -> u64 { + match self { + Machine::Recv(r) => r.next_wake_us(), + Machine::Send(s) => s.next_wake_us(), + } + } + fn closed(&self) -> Option { + match self { + Machine::Recv(r) => r.closed.clone(), + Machine::Send(s) => s.closed.clone(), + } + } + fn close(&mut self, why: &str) { + match self { + Machine::Recv(r) => r.close(why), + Machine::Send(s) => s.close(why), + } + } + fn out(&mut self) -> Vec> { + match self { + Machine::Recv(r) => std::mem::take(&mut r.out), + Machine::Send(s) => std::mem::take(&mut s.out), + } + } + fn delivered(&mut self) -> Vec> { + match self { + Machine::Recv(r) => std::mem::take(&mut r.delivered), + Machine::Send(_) => Vec::new(), + } + } +} + +pub(crate) enum Source { + Chan(mpsc::Receiver>), + Socket, +} + +async fn run(mut m: Machine, sock: Arc, peer: SocketAddr, mut src: Source, mut ends: LinkEnds, done: Option<(mpsc::UnboundedSender, u32)>) { + let mut buf = vec![0u8; 65_536]; + let t0 = m.t0(); + let reason = loop { + for p in m.out() { + let _ = sock.send_to(&p, peer).await; + } + for d in m.delivered() { + let _ = ends.data_tx.try_send(d); + } + if let Some(r) = m.closed() { + break r; + } + let wake = tokio::time::Instant::from_std(t0 + Duration::from_micros(m.next_wake_us())); + tokio::select! { + d = async { + match &mut src { + Source::Chan(rx) => rx.recv().await, + Source::Socket => loop { + match sock.recv_from(&mut buf).await { + Ok((n, from)) if from == peer => break Some(buf[..n].to_vec()), + Ok(_) => continue, + Err(_) => continue, + } + }, + } + } => match d { + Some(d) => m.on_datagram(&d), + None => m.close("listener_stopped"), + }, + p = async { + match &mut ends.send_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => match (p, &mut m) { + (Some(p), Machine::Send(s)) => s.send(p), + (None, _) => m.close("closed"), + _ => {} + }, + _ = tokio::time::sleep_until(wake) => m.on_timer(), + c = ends.cmd_rx.recv() => match c { + Some(Cmd::Close) | None => m.close("closed"), + }, + } + }; + for p in m.out() { + let _ = sock.send_to(&p, peer).await; + } + for d in m.delivered() { + let _ = ends.data_tx.try_send(d); + } + let _ = ends.closed_tx.send(Some(reason)); + if let Some((tx, id)) = done { + let _ = tx.send(id); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/srt/crypt.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/crypt.rs new file mode 100644 index 000000000..fa6e0ba44 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/crypt.rs @@ -0,0 +1,140 @@ +//! SRT's encryption (`draft-sharabayko-srt-01` §6): the stream-encrypting key (SEK) +//! wrapped (RFC 3394) under a key-encrypting key (KEK) derived from the passphrase by +//! PBKDF2-HMAC-SHA1 over the last 64 bits of the salt with 2048 iterations, the KEK as long +//! as the SEK; the payload in AES-CTR with the counter block +//! +//! ```text +//! bytes 0..14: the salt's first 112 bits, XOR the packet index (its sequence number, +//! big-endian) at bytes 10..14; bytes 14..16: the block counter, from 0. +//! ``` +//! +//! (§6.1.2: "a block counter in the least significant 16 bits", "a packet index … in the +//! next 32 bits", XORed with the salt's most significant 112 bits.) + +use super::packet::{KeyMaterial, CIPHER_AES_CTR, SE_MPEG_TS_SRT}; +use crate::crypto::{ctr_apply, key_unwrap, key_wrap, pbkdf2_hmac_sha1, AesKey}; + +pub const PBKDF2_ITER: u32 = 2048; +pub const SALT_LEN: usize = 16; + +/// The keys of one direction: the even and odd SEKs, and the salt. +#[derive(Clone, Debug)] +pub struct Keys { + pub sek: [Option>; 2], + pub salt: Vec, + pub klen: usize, +} + +/// The KEK for `passphrase`, `salt` and a key of `klen` bytes. +pub fn kek(passphrase: &str, salt: &[u8], klen: usize) -> Vec { + let s = &salt[salt.len().saturating_sub(8)..]; + pbkdf2_hmac_sha1(passphrase.as_bytes(), s, PBKDF2_ITER, klen) +} + +/// Why a key material message is not accepted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KmError { + /// It does not read, or names a cipher, an authentication or a salt this + /// implementation does not use. + Unsupported, + /// The wrap's integrity check failed: the passphrase is not the sender's. + BadSecret, +} + +impl Keys { + /// A fresh even SEK and salt of `klen` bytes, and the key material message wrapping it + /// under `passphrase`. + pub fn generate(passphrase: &str, klen: usize) -> (Keys, KeyMaterial) { + let sek = crate::random_bytes(klen); + let salt = crate::random_bytes(SALT_LEN); + let k = AesKey::new(&kek(passphrase, &salt, klen)).expect("a KEK of 16, 24 or 32 bytes"); + let wrap = key_wrap(&k, &sek).expect("a key of 16, 24 or 32 bytes wraps"); + let km = KeyMaterial { kk: 1, keki: 0, cipher: CIPHER_AES_CTR, auth: 0, se: SE_MPEG_TS_SRT, salt: salt.clone(), klen, wrap }; + (Keys { sek: [Some(sek), None], salt, klen }, km) + } + + /// The keys a key material message wraps under `passphrase`. + pub fn from_km(km: &KeyMaterial, passphrase: &str) -> Result { + if km.cipher != CIPHER_AES_CTR || km.auth != 0 || km.salt.len() != SALT_LEN || km.keki != 0 { + return Err(KmError::Unsupported); + } + let k = AesKey::new(&kek(passphrase, &km.salt, km.klen)).ok_or(KmError::Unsupported)?; + let keys = key_unwrap(&k, &km.wrap).ok_or(KmError::BadSecret)?; + let mut sek = [None, None]; + match km.kk { + 1 => sek[0] = Some(keys), + 2 => sek[1] = Some(keys), + 3 if keys.len() == 2 * km.klen => { + sek[0] = Some(keys[..km.klen].to_vec()); + sek[1] = Some(keys[km.klen..].to_vec()); + } + _ => return Err(KmError::Unsupported), + } + Ok(Keys { sek, salt: km.salt.clone(), klen: km.klen }) + } + + /// Merge the keys of a refreshed key material message (§6.1.6): each key it carries + /// replaces the one of its parity. + pub fn merge(&mut self, other: Keys) { + for (i, k) in other.sek.into_iter().enumerate() { + if k.is_some() { + self.sek[i] = k; + } + } + self.salt = other.salt; + } + + /// Encrypt or decrypt (the same operation) the payload of packet `seq` under the key of + /// parity `kk` (1 even, 2 odd). `false` when that key is not held. + pub fn apply(&self, kk: u8, seq: u32, payload: &mut [u8]) -> bool { + let Some(Some(sek)) = self.sek.get(match kk { + 1 => 0, + 2 => 1, + _ => return false, + }) else { + return false; + }; + let mut iv = [0u8; 16]; + iv[..14].copy_from_slice(&self.salt[..14]); + for (i, b) in (seq & 0x7fff_ffff).to_be_bytes().iter().enumerate() { + iv[10 + i] ^= b; + } + ctr_apply(sek, &iv, payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_wrapped_key_unwraps_under_the_passphrase_only() { + let (tx, km) = Keys::generate("a passphrase of ten+", 16); + let rx = Keys::from_km(&km, "a passphrase of ten+").unwrap(); + assert_eq!(rx.sek[0], tx.sek[0]); + assert_eq!(Keys::from_km(&km, "another passphrase").unwrap_err(), KmError::BadSecret); + let mut p = vec![0x47u8; 1316]; + assert!(tx.apply(1, 77, &mut p)); + assert_ne!(p[0], 0x47); + assert!(rx.apply(1, 77, &mut p)); + assert_eq!(p, vec![0x47u8; 1316]); + assert!(!rx.apply(2, 77, &mut p), "no odd key is held"); + } + + #[test] + fn the_counter_block_holds_the_packet_index() { + // With a zero key and salt, the first keystream block is AES_0(0^10 || seq || 0^2); + // two packets differ in their keystream. + let k = Keys { sek: [Some(vec![0; 16]), None], salt: vec![0; 16], klen: 16 }; + let mut a = vec![0u8; 16]; + let mut b = vec![0u8; 16]; + k.apply(1, 1, &mut a); + k.apply(1, 2, &mut b); + assert_ne!(a, b); + let mut iv = [0u8; 16]; + iv[13] = 1; + let mut c = vec![0u8; 16]; + crate::crypto::ctr_apply(&[0; 16], &iv, &mut c); + assert_eq!(a, c); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/srt/mod.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/mod.rs new file mode 100644 index 000000000..a29cc649f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/mod.rs @@ -0,0 +1,26 @@ +//! SRT (`draft-sharabayko-srt-01`): a listener and a caller, live mode. +//! +//! - [`packet`]: the wire format. +//! - [`crypt`]: the key material and AES-CTR. +//! - [`recv`], [`send`]: the receiving and sending sides of a connection, without I/O. +//! - [`conn`]: the listener, which serves many callers on one UDP port, each found by the +//! socket ID its packets name, and admits each by its stream ID through the +//! application's [`conn::Admission`]; and [`conn::call`], the caller. +//! +//! The handshake is the caller-listener one of §4.3.1, HSv5: the INDUCTION exchange gives +//! the caller a SYN cookie made from its address, its port and the minute, with the +//! listener's secret; the CONCLUSION carries the HSREQ, the stream ID and, for an encrypted +//! connection, the key material, and its answer the HSRSP and the key material back, or a +//! rejection: a handshake type of 1000 plus the reason code. + +pub mod conn; +pub mod crypt; +pub mod packet; +pub mod recv; +pub mod send; + +pub use conn::{call, Admission, Admitted, CallConfig, ConnRequest, Listener, ListenerConfig, Role}; + +/// The SRT version this implementation states in its HSREQ and HSRSP: 1.5.0, the HSv5 +/// handshake and live mode as the draft gives them. +pub const SRT_VERSION: u32 = 0x01_05_00; diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/srt/packet.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/packet.rs new file mode 100644 index 000000000..20bcf1602 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/packet.rs @@ -0,0 +1,495 @@ +//! SRT packets on the wire (`draft-sharabayko-srt-01` §3): the 16-byte header, data packets +//! (§3.1), control packets (§3.2) and the handshake's CIF and extensions (§3.2.1), the key +//! material message (§3.2.2), ACK (§3.2.4), NAK and its loss list (§3.2.5, Appendix A). +//! +//! Every field is big-endian, but the stream ID extension's contents, which go as 32-bit +//! words each written little-endian (§3.2.1.3). + +pub const HEADER: usize = 16; +/// The handshake CIF before its extensions (Figure 5). +pub const HS_CIF: usize = 48; + +pub mod ctrl { + pub const HANDSHAKE: u16 = 0x0000; + pub const KEEPALIVE: u16 = 0x0001; + pub const ACK: u16 = 0x0002; + pub const NAK: u16 = 0x0003; + pub const CONGESTION: u16 = 0x0004; + pub const SHUTDOWN: u16 = 0x0005; + pub const ACKACK: u16 = 0x0006; + pub const DROPREQ: u16 = 0x0007; + pub const PEERERROR: u16 = 0x0008; + pub const USER: u16 = 0x7fff; +} + +/// Handshake types (Table 4); a rejection is 1000 plus its reason code (Table 7, and the +/// access-control codes 1400 and above). +pub mod hs { + pub const DONE: u32 = 0xffff_fffd; + pub const AGREEMENT: u32 = 0xffff_fffe; + pub const CONCLUSION: u32 = 0xffff_ffff; + pub const WAVEAHAND: u32 = 0; + pub const INDUCTION: u32 = 1; + pub const FAILURE_BASE: u32 = 1000; + /// The listener's INDUCTION reply's Extension Field (§4.3.1.1). + pub const MAGIC: u16 = 0x4a17; + /// The caller's INDUCTION Extension Field (§4.3.1.1). + pub const UDT_DGRAM: u16 = 2; +} + +/// Handshake extension types (Table 5) and the Extension Field flags (Table 3). +pub mod ext { + pub const HSREQ: u16 = 1; + pub const HSRSP: u16 = 2; + pub const KMREQ: u16 = 3; + pub const KMRSP: u16 = 4; + pub const SID: u16 = 5; + pub const CONGESTION: u16 = 6; + pub const FILTER: u16 = 7; + pub const GROUP: u16 = 8; + pub const F_HSREQ: u16 = 0x1; + pub const F_KMREQ: u16 = 0x2; + pub const F_CONFIG: u16 = 0x4; +} + +/// HSREQ/HSRSP flags (Table 6). +pub mod flags { + pub const TSBPDSND: u32 = 0x01; + pub const TSBPDRCV: u32 = 0x02; + pub const CRYPT: u32 = 0x04; + pub const TLPKTDROP: u32 = 0x08; + pub const PERIODICNAK: u32 = 0x10; + pub const REXMITFLG: u32 = 0x20; + pub const STREAM: u32 = 0x40; + pub const PACKET_FILTER: u32 = 0x80; + /// What live mode needs from a peer (§7.1). + pub const LIVE: u32 = TSBPDSND | TSBPDRCV | CRYPT | TLPKTDROP | PERIODICNAK | REXMITFLG; +} + +/// Rejection reason codes: the protocol's (Table 7) and the access-control codes of the +/// stream ID convention, which a listener's callback gives. +pub mod rej { + pub const UNKNOWN: u32 = 0; + pub const ROGUE: u32 = 4; + pub const BACKLOG: u32 = 5; + pub const BADSECRET: u32 = 10; + pub const UNSECURE: u32 = 11; + pub const MESSAGEAPI: u32 = 12; + pub const BAD_REQUEST: u32 = 1400; + pub const UNAUTHORIZED: u32 = 1401; + pub const OVERLOAD: u32 = 1402; + pub const FORBIDDEN: u32 = 1403; + pub const NOTFOUND: u32 = 1404; + pub const BAD_MODE: u32 = 1405; + pub const UNACCEPTABLE: u32 = 1406; + pub const CONFLICT: u32 = 1409; +} + +fn be32(b: &[u8], i: usize) -> u32 { + u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) +} + +/// A data packet (§3.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Data { + /// 31 bits. + pub seq: u32, + /// Packet position: 0b11 a whole message. + pub pp: u8, + pub order: bool, + /// 0 clear, 1 even key, 2 odd key. + pub kk: u8, + pub rexmit: bool, + /// 26 bits. + pub msgno: u32, + pub ts: u32, + pub dst: u32, + pub payload: Vec, +} + +/// A control packet (§3.2), its CIF unread. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Control { + pub ctype: u16, + pub subtype: u16, + pub info: u32, + pub ts: u32, + pub dst: u32, + pub cif: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Packet { + Data(Data), + Control(Control), +} + +impl Packet { + pub fn read(b: &[u8]) -> Option { + if b.len() < HEADER { + return None; + } + let w0 = be32(b, 0); + let w1 = be32(b, 4); + let ts = be32(b, 8); + let dst = be32(b, 12); + Some(if w0 & 0x8000_0000 == 0 { + Packet::Data(Data { + seq: w0 & 0x7fff_ffff, + pp: (w1 >> 30) as u8, + order: w1 & 0x2000_0000 != 0, + kk: ((w1 >> 27) & 0x3) as u8, + rexmit: w1 & 0x0400_0000 != 0, + msgno: w1 & 0x03ff_ffff, + ts, + dst, + payload: b[HEADER..].to_vec(), + }) + } else { + Packet::Control(Control { ctype: ((w0 >> 16) & 0x7fff) as u16, subtype: w0 as u16, info: w1, ts, dst, cif: b[HEADER..].to_vec() }) + }) + } + + /// The destination socket ID of a datagram, without reading the rest. + pub fn dst_of(b: &[u8]) -> Option { + (b.len() >= HEADER).then(|| be32(b, 12)) + } +} + +impl Data { + pub fn write(&self) -> Vec { + let mut o = Vec::with_capacity(HEADER + self.payload.len()); + o.extend_from_slice(&(self.seq & 0x7fff_ffff).to_be_bytes()); + let w1 = ((self.pp as u32 & 0x3) << 30) | ((self.order as u32) << 29) | ((self.kk as u32 & 0x3) << 27) | ((self.rexmit as u32) << 26) | (self.msgno & 0x03ff_ffff); + o.extend_from_slice(&w1.to_be_bytes()); + o.extend_from_slice(&self.ts.to_be_bytes()); + o.extend_from_slice(&self.dst.to_be_bytes()); + o.extend_from_slice(&self.payload); + o + } +} + +impl Control { + pub fn new(ctype: u16, info: u32, ts: u32, dst: u32, cif: Vec) -> Control { + Control { ctype, subtype: 0, info, ts, dst, cif } + } + + pub fn write(&self) -> Vec { + let mut o = Vec::with_capacity(HEADER + self.cif.len()); + let w0 = 0x8000_0000 | ((self.ctype as u32 & 0x7fff) << 16) | self.subtype as u32; + o.extend_from_slice(&w0.to_be_bytes()); + o.extend_from_slice(&self.info.to_be_bytes()); + o.extend_from_slice(&self.ts.to_be_bytes()); + o.extend_from_slice(&self.dst.to_be_bytes()); + o.extend_from_slice(&self.cif); + o + } + + /// The CIF as 32-bit words. + pub fn words(&self) -> Vec { + self.cif.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect() + } +} + +/// One handshake extension block: its type and contents (a whole number of words). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ext { + pub ty: u16, + pub data: Vec, +} + +/// The handshake CIF (Figure 5) and its extensions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Handshake { + pub version: u32, + pub enc_field: u16, + pub ext_field: u16, + pub isn: u32, + pub mtu: u32, + pub flow_window: u32, + pub hs_type: u32, + pub socket_id: u32, + pub cookie: u32, + pub peer_ip: [u8; 16], + pub exts: Vec, +} + +impl Handshake { + pub fn read(cif: &[u8]) -> Option { + if cif.len() < HS_CIF { + return None; + } + let mut exts = Vec::new(); + let mut i = HS_CIF; + while i + 4 <= cif.len() { + let ty = u16::from_be_bytes([cif[i], cif[i + 1]]); + let words = u16::from_be_bytes([cif[i + 2], cif[i + 3]]) as usize; + let end = i + 4 + 4 * words; + if end > cif.len() { + return None; + } + exts.push(Ext { ty, data: cif[i + 4..end].to_vec() }); + i = end; + } + Some(Handshake { + version: be32(cif, 0), + enc_field: u16::from_be_bytes([cif[4], cif[5]]), + ext_field: u16::from_be_bytes([cif[6], cif[7]]), + isn: be32(cif, 8), + mtu: be32(cif, 12), + flow_window: be32(cif, 16), + hs_type: be32(cif, 20), + socket_id: be32(cif, 24), + cookie: be32(cif, 28), + peer_ip: cif[32..48].try_into().unwrap(), + exts, + }) + } + + pub fn write(&self) -> Vec { + let mut o = Vec::with_capacity(HS_CIF + 64); + o.extend_from_slice(&self.version.to_be_bytes()); + o.extend_from_slice(&self.enc_field.to_be_bytes()); + o.extend_from_slice(&self.ext_field.to_be_bytes()); + for w in [self.isn, self.mtu, self.flow_window, self.hs_type, self.socket_id, self.cookie] { + o.extend_from_slice(&w.to_be_bytes()); + } + o.extend_from_slice(&self.peer_ip); + for e in &self.exts { + debug_assert!(e.data.len().is_multiple_of(4)); + o.extend_from_slice(&e.ty.to_be_bytes()); + o.extend_from_slice(&((e.data.len() / 4) as u16).to_be_bytes()); + o.extend_from_slice(&e.data); + } + o + } + + pub fn ext(&self, ty: u16) -> Option<&Ext> { + self.exts.iter().find(|e| e.ty == ty) + } + + /// The Peer IP field as the reference implementation fills it: an IPv4 address in the + /// first word with its bytes reversed (it stores the address's bytes as a host-order + /// word, which the wire's byte order then reverses), the rest zero; an IPv6 address's + /// four words each reversed. A reader ignores the field. + pub fn ip_field(addr: std::net::IpAddr) -> [u8; 16] { + let mut f = [0u8; 16]; + match addr { + std::net::IpAddr::V4(a) => { + let o = a.octets(); + f[..4].copy_from_slice(&[o[3], o[2], o[1], o[0]]); + } + std::net::IpAddr::V6(a) => { + for (k, c) in a.octets().chunks(4).enumerate() { + f[4 * k..4 * k + 4].copy_from_slice(&[c[3], c[2], c[1], c[0]]); + } + } + } + f + } +} + +/// HSREQ/HSRSP contents (Figure 6). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HsReq { + pub version: u32, + pub flags: u32, + /// The sender of this message's receiver TSBPD delay, ms. + pub recv_delay_ms: u16, + /// Its sender TSBPD delay, ms. + pub send_delay_ms: u16, +} + +impl HsReq { + pub fn read(b: &[u8]) -> Option { + (b.len() >= 12).then(|| HsReq { version: be32(b, 0), flags: be32(b, 4), recv_delay_ms: u16::from_be_bytes([b[8], b[9]]), send_delay_ms: u16::from_be_bytes([b[10], b[11]]) }) + } + pub fn write(&self) -> Vec { + let mut o = Vec::with_capacity(12); + o.extend_from_slice(&self.version.to_be_bytes()); + o.extend_from_slice(&self.flags.to_be_bytes()); + o.extend_from_slice(&self.recv_delay_ms.to_be_bytes()); + o.extend_from_slice(&self.send_delay_ms.to_be_bytes()); + o + } +} + +/// The stream ID extension's contents for `s`: its bytes zero-padded to a whole number of +/// words, each word's four bytes reversed (§3.2.1.3: "stored as 32-bit little endian +/// words"). +pub fn sid_write(s: &str) -> Vec { + let mut b = s.as_bytes().to_vec(); + while !b.len().is_multiple_of(4) { + b.push(0); + } + for w in b.chunks_mut(4) { + w.reverse(); + } + b +} + +/// The stream ID back out of an extension's contents; `None` past 512 bytes or when it is +/// not UTF-8. +pub fn sid_read(b: &[u8]) -> Option { + if b.len() > 512 { + return None; + } + let mut v = b.to_vec(); + for w in v.chunks_mut(4) { + w.reverse(); + } + while v.last() == Some(&0) { + v.pop(); + } + String::from_utf8(v).ok() +} + +/// A key material message (§3.2.2, Figure 10). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KeyMaterial { + /// Which keys it carries: 1 even, 2 odd, 3 both. + pub kk: u8, + pub keki: u32, + pub cipher: u8, + pub auth: u8, + pub se: u8, + pub salt: Vec, + /// The SEK length in bytes: 16, 24 or 32. + pub klen: usize, + pub wrap: Vec, +} + +/// The KM message's fixed first word: S = 0, V = 1, PT = 2 (KMmsg), then the signature +/// 0x2029. +pub const KM_W0: [u8; 3] = [0x12, 0x20, 0x29]; +pub const CIPHER_AES_CTR: u8 = 2; +pub const SE_MPEG_TS_SRT: u8 = 2; + +impl KeyMaterial { + pub fn read(b: &[u8]) -> Option { + if b.len() < 16 || b[..3] != KM_W0 { + return None; + } + let kk = b[3] & 0x3; + let slen = b[14] as usize * 4; + let klen = b[15] as usize * 4; + let n = if kk == 3 { 2 } else { 1 }; + let wrap_len = 8 + n * klen; + if kk == 0 || !matches!(klen, 16 | 24 | 32) || b.len() < 16 + slen + wrap_len { + return None; + } + Some(KeyMaterial { kk, keki: be32(b, 4), cipher: b[8], auth: b[9], se: b[10], salt: b[16..16 + slen].to_vec(), klen, wrap: b[16 + slen..16 + slen + wrap_len].to_vec() }) + } + + pub fn write(&self) -> Vec { + let mut o = Vec::with_capacity(16 + self.salt.len() + self.wrap.len()); + o.extend_from_slice(&KM_W0); + o.push(self.kk & 0x3); + o.extend_from_slice(&self.keki.to_be_bytes()); + o.extend_from_slice(&[self.cipher, self.auth, self.se, 0, 0, 0, (self.salt.len() / 4) as u8, (self.klen / 4) as u8]); + o.extend_from_slice(&self.salt); + o.extend_from_slice(&self.wrap); + while !o.len().is_multiple_of(4) { + o.push(0); + } + o + } +} + +/// A NAK's loss list (§3.2.5, Appendix A) for the ascending, inclusive ranges `ranges` of +/// 31-bit sequence numbers: a range of one is its number, a longer one its first with the +/// top bit set, then its last. +pub fn loss_list_write(ranges: &[(u32, u32)]) -> Vec { + let mut o = Vec::with_capacity(ranges.len() * 8); + for &(a, b) in ranges { + if a == b { + o.extend_from_slice(&(a & 0x7fff_ffff).to_be_bytes()); + } else { + o.extend_from_slice(&(0x8000_0000 | (a & 0x7fff_ffff)).to_be_bytes()); + o.extend_from_slice(&(b & 0x7fff_ffff).to_be_bytes()); + } + } + o +} + +/// The ranges of a loss list; `None` when a range's start has no end. +pub fn loss_list_read(b: &[u8]) -> Option> { + let w: Vec = b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < w.len() { + if w[i] & 0x8000_0000 != 0 { + let end = *w.get(i + 1)?; + out.push((w[i] & 0x7fff_ffff, end & 0x7fff_ffff)); + i += 2; + } else { + out.push((w[i], w[i])); + i += 1; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_round_trips() { + let d = Data { seq: 0x7fff_fffe, pp: 3, order: false, kk: 1, rexmit: true, msgno: 0x03ff_ffff, ts: 123_456, dst: 0xdead_beef, payload: vec![0x47; 1316] }; + let b = d.write(); + assert_eq!(b[0] & 0x80, 0); + assert_eq!(Packet::read(&b), Some(Packet::Data(d))); + } + + #[test] + fn a_handshake_round_trips_with_extensions() { + let h = Handshake { + version: 5, + enc_field: 2, + ext_field: ext::F_HSREQ | ext::F_KMREQ | ext::F_CONFIG, + isn: 0x1234_5678, + mtu: 1500, + flow_window: 8192, + hs_type: hs::CONCLUSION, + socket_id: 0x0101_0101, + cookie: 0x55aa_55aa, + peer_ip: Handshake::ip_field("127.0.0.1".parse().unwrap()), + exts: vec![ + Ext { ty: ext::HSREQ, data: HsReq { version: 0x010500, flags: flags::LIVE, recv_delay_ms: 120, send_delay_ms: 120 }.write() }, + Ext { ty: ext::SID, data: sid_write("#!::r=live/x,m=publish") }, + ], + }; + let c = Control::new(ctrl::HANDSHAKE, 0, 7, 0, h.write()); + let Packet::Control(c2) = Packet::read(&c.write()).unwrap() else { panic!() }; + let h2 = Handshake::read(&c2.cif).unwrap(); + assert_eq!(h2, h); + assert_eq!(h2.peer_ip[..4], [1, 0, 0, 127]); + assert_eq!(sid_read(&h2.ext(ext::SID).unwrap().data).as_deref(), Some("#!::r=live/x,m=publish")); + assert_eq!(HsReq::read(&h2.ext(ext::HSREQ).unwrap().data).unwrap().recv_delay_ms, 120); + } + + #[test] + fn the_stream_id_goes_as_little_endian_words() { + assert_eq!(sid_write("abcde"), b"dcba\0\0\0e".to_vec()); + assert_eq!(sid_read(b"dcba\0\0\0e").as_deref(), Some("abcde")); + } + + #[test] + fn loss_lists() { + let r = vec![(5, 5), (10, 20), (0x7fff_fff0, 0x7fff_ffff)]; + let b = loss_list_write(&r); + assert_eq!(b.len(), 4 + 8 + 8); + assert_eq!(loss_list_read(&b).unwrap(), r); + assert!(loss_list_read(&(0x8000_0001u32).to_be_bytes()).is_none()); + } + + #[test] + fn key_material_round_trips() { + let km = KeyMaterial { kk: 1, keki: 0, cipher: CIPHER_AES_CTR, auth: 0, se: SE_MPEG_TS_SRT, salt: vec![9; 16], klen: 16, wrap: vec![3; 24] }; + let b = km.write(); + assert_eq!(&b[..4], &[0x12, 0x20, 0x29, 0x01]); + assert_eq!(b.len(), 16 + 16 + 24); + assert_eq!(KeyMaterial::read(&b).unwrap(), km); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/srt/recv.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/recv.rs new file mode 100644 index 000000000..1ba35241b --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/recv.rs @@ -0,0 +1,489 @@ +//! The receiving side of an SRT connection in live mode, without I/O: datagrams and the +//! time in, control packets and delivered payloads out. +//! +//! - **TSBPD** (§4.5): a packet is delivered at `base + timestamp + latency + drift` on the +//! receiver's clock, `base` taken when the handshake carrying the HSREQ arrived (§4.5.1.1), +//! the timestamp extended past its 32 bits, and `drift` the drift tracer's correction +//! (§4.7): the mean, over 1 000 ACKACK arrivals, of how far each arrived (less half the +//! RTT) from where `base` put it, applied once it passes 5 ms. +//! - **Too-late packet drop** (§4.6): when the next packet to deliver is missing and the +//! first packet after it is due, the missing ones are dropped. +//! - **ACK** (§3.2.4, §4.8.1): a full ACK every 10 ms while the acknowledged position moves +//! (and at least every 100 ms, so the RTT is measured), a light ACK every 64 packets; the +//! position is the first sequence number neither received nor resolved. +//! - **NAK** (§3.2.5, §4.8.2): at once for each gap found, and periodically, every +//! (RTT + 4 × RTTVar) / 2 with a 20 ms floor, for every packet still missing. +//! - **RTT** (§4.10) from ACK/ACKACK pairs: RTT = 7/8 RTT + 1/8 rtt, RTTVar = 3/4 RTTVar + +//! 1/4 |RTT − rtt|, from 100 ms and 50 ms. +//! - **Keys** (§6.3): payloads decrypted by the key of the parity their KK field names; a +//! refreshed key material message (§6.1.6, a user-defined control packet of subtype +//! KMREQ) is answered with the same message as KMRSP. + +use super::crypt::Keys; +use super::packet::{ctrl, ext, loss_list_write, Control, Data, KeyMaterial, Packet}; +use crate::seq::Extender; +use crate::stats::{Account, Outcome}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +const ACK_PERIOD_US: u64 = 10_000; +const ACK_MAX_SILENCE_US: u64 = 100_000; +const LIGHT_ACK_EVERY: u32 = 64; +const NAK_FLOOR_US: u64 = 20_000; +const KEEPALIVE_US: u64 = 1_000_000; +const DRIFT_SAMPLES: u64 = 1_000; +const DRIFT_MAX_US: i64 = 5_000; +/// The most loss-list bytes in one NAK: a datagram stays within 1500 bytes. +const NAK_MAX_CIF: usize = 1400; +/// How many dropped sequence numbers are remembered to tell a late arrival from a +/// duplicate. +const RECENT_DROPS: usize = 4096; + +struct Slot { + payload: Vec, + ts: u64, + arrived_us: u64, + /// For a packet that was found missing first: when. + missed_at: Option, +} + +struct Miss { + found_us: u64, +} + +pub struct RecvConfig { + pub latency_us: u64, + pub peer_idle_us: u64, + pub flow_window: u32, + /// Payloads must arrive encrypted. + pub require_crypto: bool, + pub keys: Option, + /// The passphrase, for refreshed key material. + pub passphrase: Option, +} + +pub struct Receiver { + cfg: RecvConfig, + pub peer_id: u32, + t0: Instant, + /// local µs = base + sender timestamp µs. + base: i64, + anchored: bool, + drift: i64, + drift_sum: i64, + drift_n: u64, + ts_ext: Extender, + seq_ext: Extender, + next: u64, + highest: u64, + buf: BTreeMap, + missing: BTreeMap, + recent_drops: BTreeSet, + recent_order: VecDeque, + pub account: Arc>, + rtt: u64, + rtt_var: u64, + pub rtt_shared: Arc, + ack_no: u32, + acks: VecDeque<(u32, u64)>, + last_full_ack_us: u64, + last_ack_pos: u64, + since_light: u32, + last_nak_us: u64, + last_sent_us: u64, + last_recv_us: u64, + rate_since_us: u64, + rate_pkts: u64, + rate_bytes: u64, + pkt_rate: u32, + byte_rate: u32, + pub undecryptable: u64, + pub out: Vec>, + pub delivered: Vec>, + pub closed: Option, +} + +impl Receiver { + /// A receiver whose peer's first data packet is `isn`, its handshake carrying the HSREQ + /// (or HSRSP) stamped `hs_ts` on the peer's clock and arriving at `t0`. + pub fn new(cfg: RecvConfig, peer_id: u32, isn: u32, hs_ts: u32, t0: Instant, account: Arc>, rtt_shared: Arc) -> Receiver { + let mut ts_ext = Extender::new(32); + let hs = ts_ext.extend(hs_ts as u64) as i64; + let mut seq_ext = Extender::new(31); + let next = seq_ext.extend(isn as u64); + Receiver { + cfg, + peer_id, + t0, + base: -hs, + anchored: false, + drift: 0, + drift_sum: 0, + drift_n: 0, + ts_ext, + seq_ext, + next, + highest: next - 1, + buf: BTreeMap::new(), + missing: BTreeMap::new(), + recent_drops: BTreeSet::new(), + recent_order: VecDeque::new(), + account, + rtt: 100_000, + rtt_var: 50_000, + rtt_shared, + ack_no: 0, + acks: VecDeque::new(), + last_full_ack_us: 0, + last_ack_pos: next, + since_light: 0, + last_nak_us: 0, + last_sent_us: 0, + last_recv_us: 0, + rate_since_us: 0, + rate_pkts: 0, + rate_bytes: 0, + pkt_rate: 0, + byte_rate: 0, + undecryptable: 0, + out: Vec::new(), + delivered: Vec::new(), + closed: None, + } + } + + pub fn now_us(&self) -> u64 { + self.t0.elapsed().as_micros() as u64 + } + + /// The instant the connection's clock counts from. + pub fn t0(&self) -> Instant { + self.t0 + } + + fn due(&self, ts: u64) -> u64 { + (self.base + ts as i64 + self.cfg.latency_us as i64 + self.drift).max(0) as u64 + } + + fn ctrl(&mut self, ctype: u16, info: u32, cif: Vec) { + let now = self.now_us(); + let c = Control::new(ctype, info, now as u32, self.peer_id, cif); + self.out.push(c.write()); + self.last_sent_us = now; + } + + fn nak_interval(&self) -> u64 { + ((self.rtt + 4 * self.rtt_var) / 2).max(NAK_FLOOR_US) + } + + /// The first sequence number neither received nor resolved. + fn ack_pos(&self) -> u64 { + match self.missing.keys().next() { + Some(&m) => m, + None => self.highest + 1, + } + .max(self.next) + } + + pub fn on_datagram(&mut self, b: &[u8]) { + let now = self.now_us(); + self.last_recv_us = now; + match Packet::read(b) { + Some(Packet::Data(d)) => self.on_data(d, now), + Some(Packet::Control(c)) => self.on_control(c, now), + None => {} + } + self.deliver(now); + } + + fn on_data(&mut self, mut d: Data, now: u64) { + if d.kk != 0 { + match &self.cfg.keys { + Some(k) if k.apply(d.kk, d.seq, &mut d.payload) => {} + _ => { + self.undecryptable += 1; + return; + } + } + } else if self.cfg.require_crypto { + self.undecryptable += 1; + return; + } + let seq = self.seq_ext.extend(d.seq as u64); + let ts = self.ts_ext.extend(d.ts as u64); + if !self.anchored { + // The handshake's timestamp sets the time base (§4.5.1.1). A peer whose first + // data packet lands more than 2 s from where it puts it stamps its handshake on + // another clock than its data: the base is taken from that packet instead. + self.anchored = true; + if (self.base + ts as i64 - now as i64).abs() > 2_000_000 { + self.base = now as i64 - ts as i64; + } + } + if d.rexmit { + self.account.lock().unwrap().retransmitted(); + } + self.rate_pkts += 1; + self.rate_bytes += d.payload.len() as u64; + if seq < self.next { + let mut a = self.account.lock().unwrap(); + if self.recent_drops.contains(&seq) { a.late() } else { a.duplicate() } + return; + } + if self.buf.contains_key(&seq) { + self.account.lock().unwrap().duplicate(); + return; + } + let missed_at = self.missing.remove(&seq).map(|m| m.found_us); + if seq > self.highest { + if seq > self.highest + 1 { + let (a, b) = (self.highest + 1, seq - 1); + for s in a..=b { + self.missing.insert(s, Miss { found_us: now }); + } + // NAK at once for the gap (§4.8.2). + self.send_nak(&[(a, b)]); + } + self.highest = seq; + } + self.buf.insert(seq, Slot { payload: d.payload, ts, arrived_us: now, missed_at }); + self.since_light += 1; + if self.since_light >= LIGHT_ACK_EVERY { + self.since_light = 0; + let pos = self.ack_pos(); + if pos > self.last_ack_pos { + self.ctrl(ctrl::ACK, 0, ((pos & 0x7fff_ffff) as u32).to_be_bytes().to_vec()); + } + } + } + + fn send_nak(&mut self, ranges: &[(u64, u64)]) { + let mut chunk: Vec<(u32, u32)> = Vec::new(); + let mut bytes = 0; + let mut named = 0u64; + for &(a, b) in ranges { + let w = if a == b { 4 } else { 8 }; + if bytes + w > NAK_MAX_CIF { + break; + } + chunk.push(((a & 0x7fff_ffff) as u32, (b & 0x7fff_ffff) as u32)); + bytes += w; + named += b - a + 1; + } + if chunk.is_empty() { + return; + } + self.account.lock().unwrap().request(named); + self.ctrl(ctrl::NAK, 0, loss_list_write(&chunk)); + } + + fn on_control(&mut self, c: Control, now: u64) { + match c.ctype { + ctrl::ACKACK => { + if let Some(i) = self.acks.iter().position(|(n, _)| *n == c.info) { + let (_, sent) = self.acks[i]; + self.acks.drain(..=i); + let rtt = now.saturating_sub(sent); + self.rtt_var = (3 * self.rtt_var + self.rtt.abs_diff(rtt)) / 4; + self.rtt = (7 * self.rtt + rtt) / 8; + self.rtt_shared.store(self.rtt, Ordering::Relaxed); + self.account.lock().unwrap().rtt_sample(rtt.min(u32::MAX as u64) as u32); + // The drift tracer (§4.7). + let ts = self.ts_ext.peek(c.ts as u64) as i64; + let sample = (now as i64 - rtt as i64 / 2) - (self.base + ts); + self.drift_sum += sample; + self.drift_n += 1; + if self.drift_n >= DRIFT_SAMPLES { + let mean = self.drift_sum / self.drift_n as i64; + if mean.abs() > DRIFT_MAX_US { + self.drift += mean; + } + self.drift_sum = 0; + self.drift_n = 0; + } + } + } + ctrl::SHUTDOWN => self.close_now("peer_shutdown"), + ctrl::USER if c.subtype == ext::KMREQ => { + // A refreshed key (§6.1.6): take the keys it wraps, and answer with the + // same message. + if let (Some(km), Some(p)) = (KeyMaterial::read(&c.cif), self.cfg.passphrase.clone()) + && let Ok(k) = Keys::from_km(&km, &p) + { + match &mut self.cfg.keys { + Some(keys) => keys.merge(k), + None => self.cfg.keys = Some(k), + } + let mut r = Control::new(ctrl::USER, 0, now as u32, self.peer_id, c.cif.clone()); + r.subtype = ext::KMRSP; + self.out.push(r.write()); + } + } + // The peer's other control packets carry nothing a receiver acts on. + _ => {} + } + } + + /// Deliver what is due, and drop what is too late (§4.6). + fn deliver(&mut self, now: u64) { + loop { + if let Some(slot) = self.buf.get(&self.next) { + if now < self.due(slot.ts) { + return; + } + let slot = self.buf.remove(&self.next).unwrap(); + let bytes = slot.payload.len() as u32; + let o = match slot.missed_at { + None => Outcome::FirstTry { bytes }, + // The margin is at most the window: a peer whose handshake reached + // the receiver later than its data does puts its packets' delivery times + // later than the window after their arrival. + Some(at) => Outcome::Recovered { + bytes, + recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32, + margin_us: self.due(slot.ts).saturating_sub(slot.arrived_us).min(self.cfg.latency_us).min(u32::MAX as u64) as u32, + }, + }; + self.account.lock().unwrap().resolve(self.next, o); + self.delivered.push(slot.payload); + self.next += 1; + continue; + } + // The next one is missing: drop it once the first packet after it is due. + let Some((&after, slot)) = self.buf.range(self.next..).next() else { return }; + if now < self.due(slot.ts) { + return; + } + let mut a = self.account.lock().unwrap(); + for s in self.next..after { + self.missing.remove(&s); + a.resolve(s, Outcome::Dropped); + self.recent_drops.insert(s); + self.recent_order.push_back(s); + if self.recent_order.len() > RECENT_DROPS + && let Some(old) = self.recent_order.pop_front() + { + self.recent_drops.remove(&old); + } + } + drop(a); + self.next = after; + } + } + + pub fn on_timer(&mut self) { + let now = self.now_us(); + self.deliver(now); + if self.closed.is_some() { + return; + } + // `last_recv_us` is 0 at the handshake, the receiver's time origin. + if now.saturating_sub(self.last_recv_us) > self.cfg.peer_idle_us { + self.close_now("idle"); + return; + } + // The rates over the last second. + if now.saturating_sub(self.rate_since_us) >= 1_000_000 { + let span = (now - self.rate_since_us).max(1); + self.pkt_rate = (self.rate_pkts * 1_000_000 / span) as u32; + self.byte_rate = (self.rate_bytes * 1_000_000 / span).min(u32::MAX as u64) as u32; + self.rate_since_us = now; + self.rate_pkts = 0; + self.rate_bytes = 0; + } + // A full ACK (§4.8.1). + let pos = self.ack_pos(); + let since = now.saturating_sub(self.last_full_ack_us); + if (pos > self.last_ack_pos && since >= ACK_PERIOD_US) || since >= ACK_MAX_SILENCE_US { + self.ack_no = self.ack_no.wrapping_add(1).max(1); + let avail = self.cfg.flow_window.saturating_sub(self.buf.len() as u32).max(2); + let cap = self.pkt_rate.max(1).saturating_mul(2); + let mut cif = Vec::with_capacity(28); + for w in [(pos & 0x7fff_ffff) as u32, self.rtt as u32, self.rtt_var as u32, avail, self.pkt_rate, cap, self.byte_rate] { + cif.extend_from_slice(&w.to_be_bytes()); + } + let n = self.ack_no; + self.ctrl(ctrl::ACK, n, cif); + self.acks.push_back((n, now)); + while self.acks.len() > 64 { + self.acks.pop_front(); + } + self.last_full_ack_us = now; + self.last_ack_pos = pos; + } + // The periodic NAK (§4.8.2). + if !self.missing.is_empty() && now.saturating_sub(self.last_nak_us) >= self.nak_interval() { + self.last_nak_us = now; + let mut ranges = Vec::new(); + for &s in self.missing.keys() { + match ranges.last_mut() { + Some((_, b)) if *b + 1 == s => *b = s, + _ => ranges.push((s, s)), + } + } + self.send_nak(&ranges); + } + if now.saturating_sub(self.last_sent_us) >= KEEPALIVE_US { + self.ctrl(ctrl::KEEPALIVE, 0, Vec::new()); + } + } + + /// When the receiver next has something to do, µs on its clock. + pub fn next_wake_us(&self) -> u64 { + let now = self.now_us(); + let mut w = now + ACK_PERIOD_US; + if let Some(s) = self.buf.get(&self.next) { + w = w.min(self.due(s.ts)); + } else if let Some((_, s)) = self.buf.range(self.next..).next() { + w = w.min(self.due(s.ts)); + } + w.max(now + 200) + } + + fn close_now(&mut self, why: &str) { + if self.closed.is_some() { + return; + } + self.closed = Some(why.into()); + self.flush(); + } + + /// The local end closes: SHUTDOWN to the peer, and what is held delivered. + pub fn close(&mut self, why: &str) { + if self.closed.is_none() { + // Three copies: SHUTDOWN is not acknowledged. + for _ in 0..3 { + self.ctrl(ctrl::SHUTDOWN, 0, Vec::new()); + } + } + self.close_now(why); + } + + /// Everything held delivered now, in order; the missing among it outstanding. + fn flush(&mut self) { + let mut a = self.account.lock().unwrap(); + while self.next <= self.highest { + match self.buf.remove(&self.next) { + Some(slot) => { + let bytes = slot.payload.len() as u32; + let o = match slot.missed_at { + None => Outcome::FirstTry { bytes }, + Some(at) => Outcome::Recovered { bytes, recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32, margin_us: 0 }, + }; + a.resolve(self.next, o); + self.delivered.push(slot.payload); + } + None => a.resolve(self.next, Outcome::Outstanding), + } + self.next += 1; + } + self.missing.clear(); + } + + /// The negotiated latency, µs. + pub fn latency_us(&self) -> u64 { + self.cfg.latency_us + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/srt/send.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/send.rs new file mode 100644 index 000000000..f3feaa574 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/srt/send.rs @@ -0,0 +1,203 @@ +//! The sending side of an SRT connection in live mode, without I/O. +//! +//! Each payload is one message of one packet (PP = 11, §7.1), stamped with the µs since +//! the connection began, encrypted once under the even key where the link is encrypted, and +//! kept until it is acknowledged or too old (§4.6: 1.25 × the latency, and at least 1 s). +//! A NAK's sequence numbers still held are sent again with the R flag set and their first +//! timestamp, ahead of new packets (§4.8.2); a full ACK is answered with an ACKACK at once +//! (§4.8.1), and its RTT taken (§4.10). + +use super::crypt::Keys; +use super::packet::{ctrl, loss_list_read, Control, Data, Packet}; +use crate::link::SendCounts; +use crate::seq::Extender; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +const KEEPALIVE_US: u64 = 1_000_000; + +struct Held { + seq: u32, + ts: u32, + kk: u8, + msgno: u32, + /// As it went on the wire: encrypted where the link is. + payload: Vec, + origin_us: u64, +} + +pub struct Sender { + pub peer_id: u32, + t0: Instant, + keep_us: u64, + peer_idle_us: u64, + next_seq: u64, + msgno: u32, + keys: Option, + held: BTreeMap, + seq_ext: Extender, + rtt: u64, + pub rtt_shared: Arc, + pub counts: Arc>, + last_sent_us: u64, + last_recv_us: u64, + pub out: Vec>, + pub closed: Option, +} + +impl Sender { + #[allow(clippy::too_many_arguments)] + pub fn new(peer_id: u32, isn: u32, latency_us: u64, peer_idle_us: u64, keys: Option, t0: Instant, counts: Arc>, rtt_shared: Arc) -> Sender { + let mut seq_ext = Extender::new(31); + let next_seq = seq_ext.extend(isn as u64); + Sender { + peer_id, + t0, + keep_us: (latency_us * 5 / 4).max(1_000_000), + peer_idle_us, + next_seq, + msgno: 1, + keys, + held: BTreeMap::new(), + seq_ext, + rtt: 100_000, + rtt_shared, + counts, + last_sent_us: 0, + last_recv_us: 0, + out: Vec::new(), + closed: None, + } + } + + pub fn now_us(&self) -> u64 { + self.t0.elapsed().as_micros() as u64 + } + + /// The instant the connection's clock counts from. + pub fn t0(&self) -> Instant { + self.t0 + } + + fn ctrl(&mut self, ctype: u16, info: u32, cif: Vec) { + let now = self.now_us(); + self.out.push(Control::new(ctype, info, now as u32, self.peer_id, cif).write()); + self.last_sent_us = now; + } + + /// One payload, as one packet. + pub fn send(&mut self, mut payload: Vec) { + if self.closed.is_some() { + return; + } + let now = self.now_us(); + let seq = (self.next_seq & 0x7fff_ffff) as u32; + let kk = match &self.keys { + Some(k) => { + k.apply(1, seq, &mut payload); + 1 + } + None => 0, + }; + let msgno = self.msgno; + self.msgno = (self.msgno % 0x03ff_ffff) + 1; + let d = Data { seq, pp: 3, order: false, kk, rexmit: false, msgno, ts: now as u32, dst: self.peer_id, payload }; + let bytes = d.payload.len() as u64; + self.out.push(d.write()); + self.held.insert(self.next_seq, Held { seq, ts: d.ts, kk, msgno, payload: d.payload, origin_us: now }); + self.next_seq += 1; + self.last_sent_us = now; + let mut c = self.counts.lock().unwrap(); + c.sent += 1; + c.bytes += bytes; + } + + pub fn on_datagram(&mut self, b: &[u8]) { + let now = self.now_us(); + self.last_recv_us = now; + let Some(Packet::Control(c)) = Packet::read(b) else { return }; + match c.ctype { + ctrl::ACK => { + let w = c.words(); + let Some(&pos) = w.first() else { return }; + let pos = self.seq_ext.extend(pos as u64); + // What is acknowledged leaves the buffer. + let keep = self.held.split_off(&pos); + self.held = keep; + if c.info != 0 { + // A full ACK: ACKACK at once, and its RTT. + self.ctrl(ctrl::ACKACK, c.info, Vec::new()); + if let Some(&rtt) = w.get(1) { + self.rtt = (7 * self.rtt + rtt as u64) / 8; + self.rtt_shared.store(self.rtt, Ordering::Relaxed); + } + } + } + ctrl::NAK => { + let Some(ranges) = loss_list_read(&c.cif) else { return }; + let mut named = 0u64; + let mut again = 0u64; + for (a, b) in ranges { + let a = self.seq_ext.peek(a as u64); + let b = self.seq_ext.peek(b as u64).max(a); + named += b - a + 1; + for (_, h) in self.held.range(a..=b) { + let d = Data { seq: h.seq, pp: 3, order: false, kk: h.kk, rexmit: true, msgno: h.msgno, ts: h.ts, dst: self.peer_id, payload: h.payload.clone() }; + self.out.push(d.write()); + again += 1; + } + } + let mut c = self.counts.lock().unwrap(); + c.requests += 1; + c.requested += named; + c.retransmitted += again; + } + ctrl::SHUTDOWN => self.closed = Some("peer_shutdown".into()), + _ => {} + } + } + + pub fn on_timer(&mut self) { + let now = self.now_us(); + if self.closed.is_some() { + return; + } + if now.saturating_sub(self.last_recv_us) > self.peer_idle_us { + self.closed = Some("idle".into()); + return; + } + // Too old to be of use (§4.6). + let old: Vec = self.held.iter().take_while(|(_, h)| now.saturating_sub(h.origin_us) > self.keep_us).map(|(k, _)| *k).collect(); + if !old.is_empty() { + self.counts.lock().unwrap().expired += old.len() as u64; + for k in old { + self.held.remove(&k); + } + } + if now.saturating_sub(self.last_sent_us) >= KEEPALIVE_US { + self.ctrl(ctrl::KEEPALIVE, 0, Vec::new()); + } + } + + pub fn next_wake_us(&self) -> u64 { + self.now_us() + 20_000 + } + + pub fn close(&mut self, why: &str) { + if self.closed.is_none() { + // SHUTDOWN is not acknowledged: three copies, so one lost does not leave the + // peer to find the end by its idle timeout. + for _ in 0..3 { + self.ctrl(ctrl::SHUTDOWN, 0, Vec::new()); + } + self.closed = Some(why.into()); + } + } + + /// Packets sent and not yet acknowledged or expired. + pub fn in_flight(&self) -> usize { + self.held.len() + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/stats.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/stats.rs new file mode 100644 index 000000000..2ed5604a3 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/stats.rs @@ -0,0 +1,361 @@ +//! The account of a receiving link: what became of every sequence number, and what it cost +//! to get there. +//! +//! A receiver resolves sequence numbers strictly in ascending order, at their delivery +//! time: each one is **delivered** (at the **first try**, or **recovered** after it was +//! found missing) or **dropped** when its delivery time passed without it. When the link +//! ends, what it holds is delivered at once, and the missing sequence numbers among it, +//! whose time had not come, are **outstanding**. So over any run of resolved sequence +//! numbers +//! +//! ```text +//! delivered + dropped + outstanding = count, first_try + recovered = delivered. +//! ``` +//! +//! The account is cut into **parts**. A part covers the sequence numbers resolved while it +//! was open, and the events (retransmission requests sent, retransmissions received, +//! duplicates, late arrivals, round-trip samples) that happened while it was open; the +//! next part begins where it ends. A **loss run** is a maximal run of consecutive +//! sequence numbers of one part none of which arrived at the first try. + +use serde::Serialize; + +/// The most loss runs a part lists; past it the part says it is truncated. +pub const MAX_RUNS: usize = 512; +/// The most samples of one kind a part keeps for its summary; past it the samples of a +/// part are thinned to every other one, so the summary stays a summary of all of them. +const MAX_SAMPLES: usize = 65_536; + +/// What became of one sequence number. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// It arrived before anything found it missing. + FirstTry { bytes: u32 }, + /// It was found missing, then arrived before its delivery time: `recovery_us` after it + /// was found missing, `margin_us` before its delivery time. + Recovered { bytes: u32, recovery_us: u32, margin_us: u32 }, + /// Its delivery time passed without it. + Dropped, + /// The link ended before it arrived and before its delivery time. + Outstanding, +} + +/// One loss run. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Run { + /// Its first sequence number, extended. + pub first: u64, + pub count: u32, + pub recovered: u32, + pub dropped: u32, + /// `count` − `recovered` − `dropped`: outstanding when the link ended. + pub outstanding: u32, +} + +/// The minimum, the median (the lower one of an even count) and the maximum of a set of +/// samples in µs, and their number. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Summary { + pub samples: u32, + pub min: u32, + pub median: u32, + pub max: u32, +} + +impl Summary { + pub fn of(v: &mut [u32], count: u64) -> Summary { + if v.is_empty() { + return Summary::default(); + } + v.sort_unstable(); + Summary { samples: count.min(u32::MAX as u64) as u32, min: v[0], median: v[(v.len() - 1) / 2], max: v[v.len() - 1] } + } +} + +#[derive(Clone, Debug, Default)] +struct Samples { + kept: Vec, + count: u64, + stride: u64, +} + +impl Samples { + fn push(&mut self, v: u32) { + self.count += 1; + let stride = self.stride.max(1); + if !(self.count - 1).is_multiple_of(stride) { + return; + } + self.kept.push(v); + if self.kept.len() >= MAX_SAMPLES { + self.kept = self.kept.iter().step_by(2).copied().collect(); + self.stride = stride * 2; + } + } + fn summary(&mut self) -> Summary { + let n = self.count; + Summary::of(&mut self.kept, n) + } +} + +/// One part of a link's account, closed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Part { + /// The sequence numbers it covers: [`seq_first`, `seq_first` + `seq_count`). + pub seq_first: u64, + pub seq_count: u64, + pub delivered: u64, + pub first_try: u64, + pub dropped: u64, + pub outstanding: u64, + /// Packets that arrived after their sequence number was dropped (an event of the + /// part's span, like the counts below). + pub late: u64, + /// The payload bytes delivered. + pub bytes: u64, + pub duplicates: u64, + /// Retransmission requests sent, and the sequence numbers they named, repeats counted. + pub requests: u64, + pub requested: u64, + /// Packets that arrived marked as retransmissions. + pub retransmitted: u64, + pub rtt: Summary, + /// From a recovered packet's being found missing to its arrival. + pub recovery: Summary, + /// The least time a recovered packet arrived before its delivery time (0 with none). + pub margin_min: u32, + pub runs: Vec, + pub runs_truncated: bool, + /// The part's span on the receiver's clock, µs since the link began. + pub start_us: u64, + pub end_us: u64, + /// Whether it is the link's last. + pub last: bool, +} + +/// The open account of a link. +#[derive(Clone, Debug, Default)] +pub struct Account { + cur: Part, + begun: bool, + rtt: Samples, + recovery: Samples, + margin_min: Option, + /// Every sequence number resolved so far, over the whole link. + pub total: Totals, +} + +/// The link's totals over every part. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Totals { + pub delivered: u64, + pub first_try: u64, + pub recovered: u64, + pub dropped: u64, + pub late: u64, + pub outstanding: u64, + pub bytes: u64, + pub duplicates: u64, + pub requests: u64, + pub requested: u64, + pub retransmitted: u64, +} + +impl Account { + pub fn new() -> Account { + Account::default() + } + + /// `seq` (extended) resolved, the next after the last one resolved. + pub fn resolve(&mut self, seq: u64, o: Outcome) { + if !self.begun { + self.begun = true; + self.cur.seq_first = seq; + } + debug_assert_eq!(seq, self.cur.seq_first + self.cur.seq_count, "sequence numbers resolve in order"); + self.cur.seq_count += 1; + let missed = !matches!(o, Outcome::FirstTry { .. }); + match o { + Outcome::FirstTry { bytes } => { + self.cur.delivered += 1; + self.cur.first_try += 1; + self.cur.bytes += bytes as u64; + self.total.delivered += 1; + self.total.first_try += 1; + self.total.bytes += bytes as u64; + } + Outcome::Recovered { bytes, recovery_us, margin_us } => { + self.cur.delivered += 1; + self.cur.bytes += bytes as u64; + self.recovery.push(recovery_us); + self.margin_min = Some(self.margin_min.map_or(margin_us, |m| m.min(margin_us))); + self.total.delivered += 1; + self.total.recovered += 1; + self.total.bytes += bytes as u64; + } + Outcome::Dropped => { + self.cur.dropped += 1; + self.total.dropped += 1; + } + Outcome::Outstanding => { + self.cur.outstanding += 1; + self.total.outstanding += 1; + } + } + if missed { + let recovered = matches!(o, Outcome::Recovered { .. }) as u32; + let dropped = matches!(o, Outcome::Dropped) as u32; + let outstanding = matches!(o, Outcome::Outstanding) as u32; + let extends = !self.cur.runs_truncated && self.cur.runs.last().is_some_and(|r| r.first + r.count as u64 == seq); + if extends { + let r = self.cur.runs.last_mut().unwrap(); + r.count += 1; + r.recovered += recovered; + r.dropped += dropped; + r.outstanding += outstanding; + } else if self.cur.runs.len() >= MAX_RUNS { + self.cur.runs_truncated = true; + } else { + self.cur.runs.push(Run { first: seq, count: 1, recovered, dropped, outstanding }); + } + } + } + + /// The next sequence number to resolve, once one has been. + pub fn next_seq(&self) -> Option { + self.begun.then_some(self.cur.seq_first + self.cur.seq_count) + } + + /// A packet arrived after its sequence number was dropped. + pub fn late(&mut self) { + self.cur.late += 1; + self.total.late += 1; + } + + pub fn duplicate(&mut self) { + self.cur.duplicates += 1; + self.total.duplicates += 1; + } + + /// A retransmission request sent, naming `n` sequence numbers. + pub fn request(&mut self, n: u64) { + self.cur.requests += 1; + self.cur.requested += n; + self.total.requests += 1; + self.total.requested += n; + } + + pub fn retransmitted(&mut self) { + self.cur.retransmitted += 1; + self.total.retransmitted += 1; + } + + pub fn rtt_sample(&mut self, us: u32) { + self.rtt.push(us); + } + + /// Close the open part at `now_us` (µs since the link began) and open the next; + /// `last` for the link's last part. + pub fn cut(&mut self, now_us: u64, last: bool) -> Part { + let mut p = std::mem::take(&mut self.cur); + p.end_us = now_us.max(p.start_us + 1); + p.rtt = self.rtt.summary(); + p.recovery = self.recovery.summary(); + p.margin_min = self.margin_min.take().unwrap_or(0); + p.last = last; + self.rtt = Samples::default(); + self.recovery = Samples::default(); + self.cur.start_us = p.end_us; + self.cur.seq_first = p.seq_first + p.seq_count; + p + } + + /// Whether the open part has nothing to cut yet: no sequence number resolved on the + /// link, or nothing in it since the last cut. A part is not cut before the first + /// sequence number resolves, so the parts' sequence numbers run on from the first: the + /// round trips measured before it fall into the first part. + pub fn open_is_empty(&self) -> bool { + !self.begun || (self.cur.seq_count == 0 && self.cur.requests == 0 && self.cur.duplicates == 0 && self.cur.retransmitted == 0 && self.cur.late == 0 && self.rtt.count == 0) + } +} + +impl Part { + /// The rules every part keeps, as the link record's verifier checks them. + pub fn check(&self) -> Result<(), &'static str> { + if self.delivered + self.dropped + self.outstanding != self.seq_count { + return Err("delivered + dropped + outstanding != count"); + } + if self.first_try > self.delivered { + return Err("first_try > delivered"); + } + if self.outstanding > 0 && !self.last { + return Err("outstanding in a part that is not the last"); + } + if self.recovery.samples as u64 != self.delivered - self.first_try { + return Err("recovery samples != recovered"); + } + if !self.runs_truncated { + let n: u64 = self.runs.iter().map(|r| r.count as u64).sum(); + let rec: u64 = self.runs.iter().map(|r| r.recovered as u64).sum(); + let drop: u64 = self.runs.iter().map(|r| r.dropped as u64).sum(); + let out: u64 = self.runs.iter().map(|r| r.outstanding as u64).sum(); + if n != self.seq_count - self.first_try || rec != self.delivered - self.first_try || drop != self.dropped || out != self.outstanding { + return Err("runs do not sum to the part's losses"); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_part_accounts_for_every_sequence_number() { + let mut a = Account::new(); + for s in 100..110u64 { + let o = match s { + 103 | 104 => Outcome::Recovered { bytes: 1316, recovery_us: 20_000 + s as u32, margin_us: 50_000 }, + 107 => Outcome::Dropped, + _ => Outcome::FirstTry { bytes: 1316 }, + }; + a.resolve(s, o); + } + a.request(2); + a.request(1); + a.rtt_sample(10_000); + a.rtt_sample(12_000); + a.rtt_sample(11_000); + let p = a.cut(1_000_000, false); + assert_eq!((p.seq_first, p.seq_count, p.delivered, p.first_try, p.dropped), (100, 10, 9, 7, 1)); + assert_eq!(p.runs, vec![Run { first: 103, count: 2, recovered: 2, dropped: 0, outstanding: 0 }, Run { first: 107, count: 1, recovered: 0, dropped: 1, outstanding: 0 }]); + assert_eq!(p.rtt, Summary { samples: 3, min: 10_000, median: 11_000, max: 12_000 }); + assert_eq!(p.recovery.samples, 2); + assert_eq!((p.requests, p.requested), (2, 3)); + p.check().unwrap(); + // The next part begins where this one ended. + a.resolve(110, Outcome::FirstTry { bytes: 10 }); + for s in 111..114 { + a.resolve(s, Outcome::Outstanding); + } + let q = a.cut(2_000_000, true); + assert_eq!((q.seq_first, q.seq_count, q.outstanding, q.start_us), (110, 4, 3, 1_000_000)); + q.check().unwrap(); + assert!(Part { outstanding: 1, seq_count: 1, last: false, runs: vec![Run { first: 0, count: 1, recovered: 0, dropped: 0, outstanding: 1 }], ..Part::default() }.check().is_err()); + } + + #[test] + fn runs_past_the_bound_truncate() { + let mut a = Account::new(); + let mut s = 0; + for _ in 0..(MAX_RUNS + 5) { + a.resolve(s, Outcome::FirstTry { bytes: 1 }); + a.resolve(s + 1, Outcome::Dropped); + s += 2; + } + let p = a.cut(10, false); + assert!(p.runs_truncated && p.runs.len() == MAX_RUNS); + p.check().unwrap(); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/src/ts.rs b/joule-code/joule-code-rs/crates/joule-contrib/src/ts.rs new file mode 100644 index 000000000..8192d98ba --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/src/ts.rs @@ -0,0 +1,696 @@ +//! MPEG-2 transport streams (ISO/IEC 13818-1), the payload SRT and RIST carry in live +//! contribution: a demultiplexer, a multiplexer, and a pacer. +//! +//! - [`Demux`] reads 188-byte packets (resynchronising on the sync byte), the PAT and the +//! first program's PMT (each section's CRC checked), and reassembles each elementary +//! stream's PES packets. It hands over, per PES, the stream's type and PID, the PTS +//! extended past its 33 bits, and the PES payload: for H.264 (stream type 0x1B) and HEVC +//! (0x24) one access unit in Annex B, for AAC in ADTS (0x0F) one or more ADTS frames, +//! which [`adts_frames`] splits. A PES whose packets did not all arrive (a gap in the +//! continuity counter) is dropped and counted, never handed over cut. +//! - [`Mux`] writes a program of one video and one audio stream: PAT and PMT, PES packets +//! with PTS (and DTS), the PCR on the video PID. For the senders and the tests. +//! - [`Pacer`] gives each 188 × n-byte chunk of a stream the time its PCR says it is due. + +use crate::seq::Extender; +use std::collections::BTreeMap; + +pub const PACKET: usize = 188; +pub const SYNC: u8 = 0x47; +pub const PID_PAT: u16 = 0; +pub const PID_NULL: u16 = 0x1fff; + +pub const ST_AAC_ADTS: u8 = 0x0f; +pub const ST_H264: u8 = 0x1b; +pub const ST_HEVC: u8 = 0x24; + +/// The MPEG-2 CRC-32 of PSI sections (ISO/IEC 13818-1 Annex A): polynomial 0x04C11DB7, +/// initial value 0xFFFFFFFF, not reflected, no final XOR. +pub fn crc32_mpeg(b: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for &x in b { + crc ^= (x as u32) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04c1_1db7 } else { crc << 1 }; + } + } + crc +} + +/// One elementary stream the PMT declares. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stream { + pub pid: u16, + pub stream_type: u8, +} + +/// One PES packet, reassembled. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pes { + pub pid: u16, + pub stream_type: u8, + /// The PTS at 90 kHz, extended past 33 bits; the DTS where the header has one. + pub pts: Option, + pub dts: Option, + /// The payload after the PES header. + pub data: Vec, + /// Whether its first TS packet's adaptation field set `random_access_indicator`. + pub random_access: bool, +} + +/// What the demultiplexer counted. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct DemuxCounts { + pub packets: u64, + /// Bytes skipped to find a sync byte. + pub resync_bytes: u64, + pub transport_errors: u64, + pub continuity_gaps: u64, + pub crc_errors: u64, + pub pes_out: u64, + pub pes_dropped: u64, + /// Packets of PIDs neither PSI nor a declared stream. + pub other_packets: u64, +} + +struct Assembly { + stream_type: u8, + buf: Vec, + started: bool, + broken: bool, + random_access: bool, + cc: Option, + /// The PES_packet_length, where it is not 0. + expect: Option, +} + +/// A transport stream demultiplexer for one program. +pub struct Demux { + carry: Vec, + pmt_pid: Option, + pub streams: Vec, + pub pcr_pid: Option, + asm: BTreeMap, + psi: BTreeMap, Option)>, + pub counts: DemuxCounts, + pts_extenders: BTreeMap, +} + +impl Default for Demux { + fn default() -> Self { + Demux::new() + } +} + +impl Demux { + pub fn new() -> Demux { + Demux { carry: Vec::new(), pmt_pid: None, streams: Vec::new(), pcr_pid: None, asm: BTreeMap::new(), psi: BTreeMap::new(), counts: DemuxCounts::default(), pts_extenders: BTreeMap::new() } + } + + /// Feed bytes; returns every PES completed by them. + pub fn push(&mut self, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut b = std::mem::take(&mut self.carry); + b.extend_from_slice(bytes); + let mut i = 0; + while i + PACKET <= b.len() { + if b[i] != SYNC || (i + PACKET < b.len() && b[i + PACKET] != SYNC && b.len() - i >= 2 * PACKET) { + i += 1; + self.counts.resync_bytes += 1; + continue; + } + let p: [u8; PACKET] = b[i..i + PACKET].try_into().unwrap(); + self.packet(&p, &mut out); + i += PACKET; + } + self.carry = b[i..].to_vec(); + out + } + + /// The PES still being assembled, handed over where whole: at the end of a stream. + pub fn flush(&mut self) -> Vec { + let mut out = Vec::new(); + let pids: Vec = self.asm.keys().copied().collect(); + for pid in pids { + self.finish(pid, &mut out); + } + out + } + + fn packet(&mut self, p: &[u8; PACKET], out: &mut Vec) { + self.counts.packets += 1; + let tei = p[1] & 0x80 != 0; + let pusi = p[1] & 0x40 != 0; + let pid = (((p[1] & 0x1f) as u16) << 8) | p[2] as u16; + let afc = (p[3] >> 4) & 0x3; + let cc = p[3] & 0x0f; + if tei { + self.counts.transport_errors += 1; + if let Some(a) = self.asm.get_mut(&pid) { + a.broken = true; + } + return; + } + if pid == PID_NULL { + return; + } + let mut off = 4; + let mut random_access = false; + let mut discontinuity = false; + if afc & 0x2 != 0 { + let len = p[4] as usize; + if len > 0 && 5 + len <= PACKET { + random_access = p[5] & 0x40 != 0; + discontinuity = p[5] & 0x80 != 0; + } + off = 5 + len; + } + let has_payload = afc & 0x1 != 0; + if off > PACKET { + return; + } + let payload = if has_payload { &p[off..] } else { &[][..] }; + + if pid == PID_PAT || Some(pid) == self.pmt_pid { + self.psi_packet(pid, pusi, cc, has_payload, payload); + return; + } + let Some(st) = self.streams.iter().find(|s| s.pid == pid).map(|s| s.stream_type) else { + self.counts.other_packets += 1; + return; + }; + let a = self.asm.entry(pid).or_insert_with(|| Assembly { stream_type: st, buf: Vec::new(), started: false, broken: false, random_access: false, cc: None, expect: None }); + // The continuity counter advances on packets with a payload; a repeat is a + // duplicate, a jump is a loss (§2.4.3.3), unless the adaptation field says so. + if has_payload { + if let Some(prev) = a.cc { + if cc == prev && !discontinuity { + return; + } + if cc != (prev + 1) & 0x0f && !discontinuity { + self.counts.continuity_gaps += 1; + a.broken = true; + } + } + a.cc = Some(cc); + } + if pusi { + self.finish(pid, out); + let a = self.asm.get_mut(&pid).unwrap(); + a.buf.clear(); + a.buf.extend_from_slice(payload); + a.started = true; + a.broken = false; + a.random_access = random_access; + a.expect = None; + if a.buf.len() >= 6 && a.buf[..3] == [0, 0, 1] { + let n = u16::from_be_bytes([a.buf[4], a.buf[5]]) as usize; + a.expect = (n > 0).then_some(6 + n); + } + } else if a.started { + a.buf.extend_from_slice(payload); + } + let a = self.asm.get(&pid).unwrap(); + if a.expect.is_some_and(|n| a.buf.len() >= n) { + self.finish(pid, out); + } + } + + fn finish(&mut self, pid: u16, out: &mut Vec) { + let Some(a) = self.asm.get_mut(&pid) else { return }; + if !a.started { + return; + } + a.started = false; + let buf = std::mem::take(&mut a.buf); + if a.broken { + a.broken = false; + self.counts.pes_dropped += 1; + return; + } + let buf = match a.expect { + Some(n) if buf.len() >= n => &buf[..n], + Some(_) => { + self.counts.pes_dropped += 1; + return; + } + None => &buf[..], + }; + let Some((pts, dts, data)) = pes_payload(buf) else { + self.counts.pes_dropped += 1; + return; + }; + let ext = self.pts_extenders.entry(pid).or_insert_with(|| Extender::new(33)); + let pts = pts.map(|v| ext.extend(v)); + let dts = dts.map(|v| ext.peek(v)); + self.counts.pes_out += 1; + out.push(Pes { pid, stream_type: a.stream_type, pts, dts, data: data.to_vec(), random_access: a.random_access }); + } + + fn psi_packet(&mut self, pid: u16, pusi: bool, cc: u8, has_payload: bool, payload: &[u8]) { + if !has_payload { + return; + } + let e = self.psi.entry(pid).or_insert_with(|| (Vec::new(), None)); + if let Some(prev) = e.1 + && cc != (prev + 1) & 0x0f + { + e.0.clear(); + } + e.1 = Some(cc); + if pusi { + let ptr = payload.first().copied().unwrap_or(0) as usize; + if 1 + ptr > payload.len() { + e.0.clear(); + return; + } + e.0.clear(); + e.0.extend_from_slice(&payload[1 + ptr..]); + } else if !e.0.is_empty() { + e.0.extend_from_slice(payload); + } + let sec = e.0.clone(); + if sec.len() < 3 || sec[0] == 0xff { + return; + } + let len = (((sec[1] & 0x0f) as usize) << 8) | sec[2] as usize; + if sec.len() < 3 + len { + return; + } + let section = &sec[..3 + len]; + self.psi.get_mut(&pid).unwrap().0.clear(); + if len < 9 || crc32_mpeg(section) != 0 { + self.counts.crc_errors += 1; + return; + } + let body = §ion[8..section.len() - 4]; + match section[0] { + 0x00 if pid == PID_PAT => { + for e in body.as_chunks::<4>().0 { + let program = u16::from_be_bytes([e[0], e[1]]); + let p = (((e[2] & 0x1f) as u16) << 8) | e[3] as u16; + if program != 0 { + self.pmt_pid = Some(p); + break; + } + } + } + 0x02 if Some(pid) == self.pmt_pid => { + if body.len() < 4 { + return; + } + self.pcr_pid = Some((((body[0] & 0x1f) as u16) << 8) | body[1] as u16); + let info = (((body[2] & 0x0f) as usize) << 8) | body[3] as usize; + let mut i = 4 + info; + let mut streams = Vec::new(); + while i + 5 <= body.len() { + let st = body[i]; + let p = (((body[i + 1] & 0x1f) as u16) << 8) | body[i + 2] as u16; + let es = (((body[i + 3] & 0x0f) as usize) << 8) | body[i + 4] as usize; + streams.push(Stream { pid: p, stream_type: st }); + i += 5 + es; + } + self.streams = streams; + } + _ => {} + } + } +} + +/// A PES packet's PTS, DTS and payload (§2.4.3.6, §2.4.3.7). `None` when the header does +/// not read. +pub fn pes_payload(b: &[u8]) -> Option<(Option, Option, &[u8])> { + if b.len() < 9 || b[..3] != [0, 0, 1] { + return None; + } + let sid = b[3]; + // Streams without the optional header (padding, private stream 2, and the like). + if matches!(sid, 0xbc | 0xbe | 0xbf | 0xf0 | 0xf1 | 0xff | 0xf2 | 0xf8) { + return Some((None, None, &b[6..])); + } + if b[6] & 0xc0 != 0x80 { + return None; + } + let flags = b[7] >> 6; + let hl = b[8] as usize; + if b.len() < 9 + hl { + return None; + } + let ts = |p: &[u8]| -> u64 { (((p[0] >> 1) & 0x07) as u64) << 30 | (p[1] as u64) << 22 | ((p[2] >> 1) as u64) << 15 | (p[3] as u64) << 7 | (p[4] >> 1) as u64 }; + let (mut pts, mut dts) = (None, None); + if flags & 0x2 != 0 && hl >= 5 { + pts = Some(ts(&b[9..14])); + } + if flags == 0x3 && hl >= 10 { + dts = Some(ts(&b[14..19])); + } + Some((pts, dts, &b[9 + hl..])) +} + +/// The ADTS frames (ISO/IEC 13818-7 §6.2) of an AAC PES payload: each frame whole, its +/// header included. Bytes that do not form a whole frame are left out. +pub fn adts_frames(b: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut i = 0; + while i + 7 <= b.len() { + if b[i] != 0xff || b[i + 1] & 0xf6 != 0xf0 { + break; + } + let len = (((b[i + 3] & 0x03) as usize) << 11) | (b[i + 4] as usize) << 3 | (b[i + 5] >> 5) as usize; + if len < 7 || i + len > b.len() { + break; + } + out.push(&b[i..i + len]); + i += len; + } + out +} + +/// An ADTS header's audio object type (its profile plus one), sampling rate and channel +/// configuration. +pub fn adts_config(f: &[u8]) -> Option<(u8, u32, u8)> { + if f.len() < 7 || f[0] != 0xff || f[1] & 0xf6 != 0xf0 { + return None; + } + const RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350]; + let profile = f[2] >> 6; + let sfi = ((f[2] >> 2) & 0x0f) as usize; + let ch = ((f[2] & 0x01) << 2) | (f[3] >> 6); + Some((profile + 1, *RATES.get(sfi)?, ch)) +} + +// ── The multiplexer ─────────────────────────────────────────────────────────────── + +/// One program of one video and one audio stream, for the senders and the tests. +pub struct Mux { + pub video_pid: u16, + pub audio_pid: u16, + pub video_type: u8, + pub pmt_pid: u16, + cc: BTreeMap, + psi_every: u32, + since_psi: u32, +} + +impl Mux { + pub fn new(video_type: u8) -> Mux { + Mux { video_pid: 0x100, audio_pid: 0x101, video_type, pmt_pid: 0x1000, cc: BTreeMap::new(), psi_every: 10, since_psi: u32::MAX } + } + + fn next_cc(&mut self, pid: u16) -> u8 { + let c = self.cc.entry(pid).or_insert(0x0f); + *c = (*c + 1) & 0x0f; + *c + } + + fn section_packets(&mut self, pid: u16, section: &[u8]) -> Vec { + let mut p = vec![SYNC, 0x40 | (pid >> 8) as u8, pid as u8, 0x10 | self.next_cc(pid), 0]; + p.extend_from_slice(section); + p.resize(PACKET, 0xff); + p + } + + /// The PAT and the PMT. + pub fn psi(&mut self) -> Vec { + let mut pat = vec![0x00, 0xb0, 0, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x00, 0x01, 0xe0 | (self.pmt_pid >> 8) as u8, self.pmt_pid as u8]; + let l = pat.len() - 3 + 4; + pat[1] = 0xb0 | (l >> 8) as u8; + pat[2] = l as u8; + let crc = crc32_mpeg(&pat); + pat.extend_from_slice(&crc.to_be_bytes()); + let mut pmt = vec![0x02, 0xb0, 0, 0x00, 0x01, 0xc1, 0x00, 0x00, 0xe0 | (self.video_pid >> 8) as u8, self.video_pid as u8, 0xf0, 0x00]; + for (st, pid) in [(self.video_type, self.video_pid), (ST_AAC_ADTS, self.audio_pid)] { + pmt.extend_from_slice(&[st, 0xe0 | (pid >> 8) as u8, pid as u8, 0xf0, 0x00]); + } + let l = pmt.len() - 3 + 4; + pmt[1] = 0xb0 | (l >> 8) as u8; + pmt[2] = l as u8; + let crc = crc32_mpeg(&pmt); + pmt.extend_from_slice(&crc.to_be_bytes()); + let mut out = self.section_packets(PID_PAT, &pat); + let pmt_pid = self.pmt_pid; + out.extend(self.section_packets(pmt_pid, &pmt)); + out + } + + fn ts_field(prefix: u8, v: u64) -> [u8; 5] { + let v = v & ((1u64 << 33) - 1); + [(prefix << 4) | (((v >> 30) as u8 & 0x07) << 1) | 1, (v >> 22) as u8, (((v >> 15) as u8) << 1) | 1, (v >> 7) as u8, ((v as u8) << 1) | 1] + } + + /// One access unit of video (`video`) or one or more ADTS frames of audio as a PES, in + /// TS packets; the PAT and PMT before it every so often, and the PCR on the video + /// PID's first packet of each PES (at `pts` less 100 ms). + pub fn pes(&mut self, video: bool, pts: u64, data: &[u8], key: bool) -> Vec { + let mut out = Vec::new(); + if self.since_psi >= self.psi_every || key { + out.extend(self.psi()); + self.since_psi = 0; + } + self.since_psi += 1; + let pid = if video { self.video_pid } else { self.audio_pid }; + let mut pes = vec![0, 0, 1, if video { 0xe0 } else { 0xc0 }, 0, 0, 0x80, 0x80, 5]; + pes.extend_from_slice(&Self::ts_field(0x2, pts)); + pes.extend_from_slice(data); + let n = pes.len() - 6; + if !video || n <= 0xffff { + let n = if video { 0 } else { n.min(0xffff) }; + pes[4..6].copy_from_slice(&(n as u16).to_be_bytes()); + } + let mut first = true; + let mut i = 0; + while i < pes.len() { + let mut af = Vec::new(); + if first && video { + let pcr = pts.saturating_sub(9_000); + let base = pcr & ((1u64 << 33) - 1); + af.push(if key { 0x50 } else { 0x10 }); + af.extend_from_slice(&[(base >> 25) as u8, (base >> 17) as u8, (base >> 9) as u8, (base >> 1) as u8, (((base & 1) as u8) << 7) | 0x7e, 0x00]); + } else if first && key { + af.push(0x40); + } + let room = PACKET - 4 - if af.is_empty() { 0 } else { 1 + af.len() }; + let left = pes.len() - i; + let take = left.min(room); + // Stuff the last packet through its adaptation field. + if take < room { + let pad = room - take; + if af.is_empty() { + // An adaptation field of `pad` bytes: its length byte, then flags and + // stuffing. + af = if pad == 1 { Vec::new() } else { std::iter::once(0x00u8).chain(std::iter::repeat_n(0xff, pad - 2)).collect() }; + let mut h = vec![SYNC, (pid >> 8) as u8 | if first { 0x40 } else { 0 }, pid as u8, 0x30 | self.next_cc(pid), (pad - 1) as u8]; + h.extend_from_slice(&af); + h.extend_from_slice(&pes[i..i + take]); + debug_assert_eq!(h.len(), PACKET); + out.extend(h); + i += take; + first = false; + continue; + } + af.extend(std::iter::repeat_n(0xff, pad)); + } + let afc = if af.is_empty() { 0x10 } else { 0x30 }; + let mut h = vec![SYNC, (pid >> 8) as u8 | if first { 0x40 } else { 0 }, pid as u8, afc | self.next_cc(pid)]; + if !af.is_empty() { + h.push(af.len() as u8); + h.extend_from_slice(&af); + } + h.extend_from_slice(&pes[i..i + take]); + debug_assert_eq!(h.len(), PACKET, "a TS packet is 188 bytes"); + out.extend(h); + i += take; + first = false; + } + out + } +} + +/// The PCR of a TS packet, in 27 MHz units, where its adaptation field carries one. +pub fn pcr_of(p: &[u8]) -> Option<(u16, u64)> { + if p.len() < 12 || p[0] != SYNC || p[3] & 0x20 == 0 || p[4] < 7 || p[5] & 0x10 == 0 { + return None; + } + let pid = (((p[1] & 0x1f) as u16) << 8) | p[2] as u16; + let base = (p[6] as u64) << 25 | (p[7] as u64) << 17 | (p[8] as u64) << 9 | (p[9] as u64) << 1 | (p[10] >> 7) as u64; + let ext = (((p[10] & 1) as u64) << 8) | p[11] as u64; + Some((pid, base * 300 + ext)) +} + +/// Times each chunk of a transport stream by its PCR: a chunk is due when the stream's +/// clock, the first PCR's PID's PCRs interpolated by byte position, reaches its first byte. +pub struct Pacer { + chunks: Vec<(u64, Vec)>, +} + +impl Pacer { + /// Cut `ts` into chunks of `n` packets and time them, in µs from the first. + pub fn new(ts: &[u8], n: usize) -> Pacer { + // The PCR wraps at 2^33 × 300 (27 MHz). + const WRAP: u64 = (1u64 << 33) * 300; + let mut pcrs: Vec<(usize, u64)> = Vec::new(); + let mut pid = None; + let mut wraps = 0u64; + for (k, p) in ts.as_chunks::().0.iter().enumerate() { + if let Some((q, v)) = pcr_of(p) + && pid.is_none_or(|x| x == q) + { + pid = Some(q); + if let Some(&(_, last)) = pcrs.last() + && v + wraps * WRAP + WRAP / 2 < last + { + wraps += 1; + } + pcrs.push((k * PACKET, v + wraps * WRAP)); + } + } + let n = n.max(1); + let at = |pos: usize| -> u64 { + if pcrs.len() < 2 { + return 0; + } + let v0 = pcrs[0].1; + let i = pcrs.partition_point(|x| x.0 <= pos).clamp(1, pcrs.len() - 1); + let (pa, va) = pcrs[i - 1]; + let (pb, vb) = pcrs[i]; + let v = if pb == pa { va as f64 } else { va as f64 + (vb as f64 - va as f64) * (pos as f64 - pa as f64) / (pb as f64 - pa as f64) }; + ((v - v0 as f64).max(0.0) / 27.0) as u64 + }; + let chunks = ts.chunks(PACKET * n).enumerate().map(|(k, c)| (at(k * PACKET * n), c.to_vec())).collect(); + Pacer { chunks } + } + + /// The chunks with their due times, µs from the first. + pub fn chunks(&self) -> &[(u64, Vec)] { + &self.chunks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn au(i: u64, key: bool) -> Vec { + let mut f = vec![0, 0, 0, 1, 0x09, 0xf0]; + if key { + f.extend_from_slice(&[0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0xac]); + f.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xee, 0x3c, 0x80]); + f.extend_from_slice(&[0, 0, 0, 1, 0x65]); + } else { + f.extend_from_slice(&[0, 0, 0, 1, 0x41]); + } + f.extend((0..(500 + i * 37 % 900)).map(|j| ((i * 13 + j) % 251 + 1) as u8)); + f + } + + fn adts(i: u64) -> Vec { + let body: Vec = (0..(120 + i % 30)).map(|j| ((i + j) % 200 + 3) as u8).collect(); + let len = body.len() + 7; + // AAC-LC (profile 1), 48 kHz (index 3), stereo. + let mut f = vec![0xff, 0xf1, (1 << 6) | (3 << 2), (2 << 6) | ((len >> 11) as u8 & 0x3), (len >> 3) as u8, ((len as u8 & 0x7) << 5) | 0x1f, 0xfc]; + f.extend(body); + f + } + + #[test] + fn crc_of_a_known_pat() { + // A PAT of program 1 on PID 0x1000, as common muxers write it. + let pat = [0x00, 0xb0, 0x0d, 0x00, 0x01, 0xc1, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x00]; + let crc = crc32_mpeg(&pat); + let mut all = pat.to_vec(); + all.extend_from_slice(&crc.to_be_bytes()); + assert_eq!(crc32_mpeg(&all), 0, "a section with its CRC appended checks to zero"); + } + + #[test] + fn mux_then_demux_gives_the_units_back() { + let mut m = Mux::new(ST_H264); + let mut ts = Vec::new(); + let mut sent_v = Vec::new(); + let mut sent_a = Vec::new(); + for i in 0..60u64 { + let v = au(i, i % 30 == 0); + ts.extend(m.pes(true, 900_000 + i * 3000, &v, i % 30 == 0)); + sent_v.push((900_000 + i * 3000, v)); + let a = [adts(2 * i), adts(2 * i + 1)].concat(); + ts.extend(m.pes(false, 900_000 + i * 3840, &a, false)); + sent_a.push(a); + } + assert!(ts.len().is_multiple_of(PACKET)); + let mut d = Demux::new(); + let mut got = Vec::new(); + // Fed in uneven pieces, as datagrams arrive. + for c in ts.chunks(1316 + 5) { + got.extend(d.push(c)); + } + got.extend(d.flush()); + let v: Vec<_> = got.iter().filter(|p| p.stream_type == ST_H264).collect(); + let a: Vec<_> = got.iter().filter(|p| p.stream_type == ST_AAC_ADTS).collect(); + assert_eq!(v.len(), 60); + for (p, (pts, data)) in v.iter().zip(&sent_v) { + assert_eq!(p.data, *data); + assert_eq!(p.pts.unwrap() % (1 << 33), *pts); + } + assert!(v[0].random_access && !v[1].random_access); + assert_eq!(a.len(), 60); + for (p, data) in a.iter().zip(&sent_a) { + assert_eq!(p.data, *data); + let f = adts_frames(&p.data); + assert_eq!(f.len(), 2); + assert_eq!(adts_config(f[0]), Some((2, 48_000, 2))); + } + assert_eq!(d.counts.pes_dropped, 0); + assert_eq!(d.counts.continuity_gaps, 0); + } + + #[test] + fn a_lost_packet_drops_its_pes_and_no_other() { + let mut m = Mux::new(ST_HEVC); + let mut ts = Vec::new(); + for i in 0..10u64 { + ts.extend(m.pes(true, i * 3000, &au(i, i == 0), i == 0)); + } + // Drop a packet in the middle of some PES: one that does not start a PES, after the + // first few. + let k = (6..ts.len() / PACKET).find(|&k| ts[k * PACKET + 1] & 0x40 == 0 && ts[k * PACKET + 2] == 0x00 && ts[k * PACKET + 1] & 0x1f == 0x01).expect("a continuation packet"); + let mut cut = ts.clone(); + cut.drain(k * PACKET..(k + 1) * PACKET); + let mut d = Demux::new(); + let mut got = d.push(&cut); + got.extend(d.flush()); + assert_eq!(d.counts.continuity_gaps, 1); + assert_eq!(d.counts.pes_dropped, 1); + assert_eq!(got.len(), 9); + } + + #[test] + fn the_pts_extends_past_33_bits() { + let mut m = Mux::new(ST_H264); + let mut ts = Vec::new(); + let top = (1u64 << 33) - 6000; + for i in 0..4u64 { + ts.extend(m.pes(true, (top + i * 3000) & ((1 << 33) - 1), &au(i, i == 0), i == 0)); + } + let mut d = Demux::new(); + let mut got = d.push(&ts); + got.extend(d.flush()); + let p: Vec = got.iter().map(|x| x.pts.unwrap()).collect(); + assert!(p.windows(2).all(|w| w[1] == w[0] + 3000), "{p:?}"); + } + + #[test] + fn the_pacer_times_by_pcr() { + let mut m = Mux::new(ST_H264); + let mut ts = Vec::new(); + for i in 0..90u64 { + ts.extend(m.pes(true, 90_000 + i * 3000, &au(i, i % 30 == 0), i % 30 == 0)); + } + let p = Pacer::new(&ts, 7); + let c = p.chunks(); + assert_eq!(c[0].0, 0); + let last = c.last().unwrap().0; + // 89 frames at 1/30 s apart, give or take one frame's bytes. + assert!((2_900_000..=3_000_000).contains(&last), "{last}"); + assert!(c.windows(2).all(|w| w[1].0 >= w[0].0)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/tests/interop.rs b/joule-code/joule-code-rs/crates/joule-contrib/tests/interop.rs new file mode 100644 index 000000000..9b34e4fc2 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/tests/interop.rs @@ -0,0 +1,292 @@ +//! Interoperation with the reference tools of each transport, run locally as test peers: +//! their SRT caller and listener, their RIST sender and receiver of both profiles, against +//! this crate's listener, caller, receiver and sender, byte for byte, in the clear and +//! encrypted, and through a path that drops datagrams each way, so each side's +//! retransmission answers the other's requests. +//! +//! The tools are named by environment variables, and these tests are ignored unless asked +//! for: run them with `--ignored`, and then a tool that is not named is a failure, never a +//! skip. +//! +//! ```text +//! WAI_SRT_LIVE_TRANSMIT=…/srt-live-transmit WAI_RISTSENDER=…/ristsender \ +//! WAI_RISTRECEIVER=…/ristreceiver cargo test -p joule-contrib --features rist --test interop -- --ignored +//! ``` + +use joule_contrib::link::Link; +use joule_contrib::lossy::{Impair, Proxy}; +use joule_contrib::srt::{self, Admitted, CallConfig, ConnRequest, ListenerConfig, Role}; +use std::net::{SocketAddr, UdpSocket}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; + +const STEP: Duration = Duration::from_secs(30); + +fn tool(var: &str) -> String { + std::env::var(var).unwrap_or_else(|_| panic!("{var} names the reference tool this test runs; it is not set")) +} + +/// A child process killed when dropped. +struct Child(std::process::Child); + +impl Drop for Child { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn spawn(bin: &str, args: &[&str], stdout: bool) -> Child { + let mut c = Command::new(bin); + c.args(args).stdin(Stdio::null()).stderr(Stdio::null()); + c.stdout(if stdout { Stdio::piped() } else { Stdio::null() }); + Child(c.spawn().unwrap_or_else(|e| panic!("{bin}: {e}"))) +} + +/// A free UDP port on loopback (even, when `even`). +fn free_port(even: bool) -> u16 { + loop { + let s = UdpSocket::bind("127.0.0.1:0").unwrap(); + let p = s.local_addr().unwrap().port(); + drop(s); + if (!even || p.is_multiple_of(2)) && p < 65_000 && UdpSocket::bind(("127.0.0.1", p + 1)).is_ok() { + return p; + } + } +} + +/// The program every test sends: four seconds of a synthetic transport stream, timed. +fn program() -> (Vec<(u64, Vec)>, Vec) { + let c = joule_contrib::cli::synthetic(4, 2500); + let all = c.iter().flat_map(|x| x.1.clone()).collect(); + (c, all) +} + +/// Send the chunks as UDP datagrams to `to`, each at its time. +async fn feed_udp(chunks: Vec<(u64, Vec)>, to: SocketAddr) { + let s = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let start = tokio::time::Instant::now(); + for (at, c) in chunks { + tokio::time::sleep_until(start + Duration::from_micros(at)).await; + s.send_to(&c, to).await.unwrap(); + } +} + +/// Every UDP datagram that reaches `port` until none has come for `idle`. +#[cfg_attr(not(feature = "rist"), allow(dead_code))] +async fn capture_udp(port: u16, idle: Duration) -> Vec { + let s = tokio::net::UdpSocket::bind(("127.0.0.1", port)).await.unwrap(); + let mut out = Vec::new(); + let mut b = vec![0u8; 65_536]; + let mut wait = STEP; + while let Ok(Ok(n)) = timeout(wait, s.recv(&mut b)).await { + out.extend_from_slice(&b[..n]); + wait = idle; + } + out +} + +async fn drain(link: &mut Link, quiet: Duration) -> Vec { + let mut out = Vec::new(); + let mut wait = STEP; + while let Ok(Some(p)) = timeout(wait, link.data.recv()).await { + out.extend_from_slice(&p); + wait = quiet; + } + out +} + +fn lossy() -> (Impair, Impair) { + (Impair { loss_ppm: 20_000, spare_first: 10, ..Impair::default() }, Impair { loss_ppm: 20_000, ..Impair::default() }) +} + +// ── SRT ──────────────────────────────────────────────────────────────────────────── + +async fn srt_reference_caller(passphrase: Option<&str>, pbkeylen: usize) { + let slt = tool("WAI_SRT_LIVE_TRANSMIT"); + let pass = passphrase.map(str::to_string); + let adm = move |_: &ConnRequest| Ok(Admitted { passphrase: pass.clone(), role: Role::Receive, latency_ms: Some(300), tag: 0 }); + let mut l = srt::Listener::bind(ListenerConfig { latency_ms: 300, ..ListenerConfig::default() }, Arc::new(adm)).await.unwrap(); + let (up, down) = lossy(); + let px = Proxy::start(l.addr, up, down, 21).await.unwrap(); + let udp_in = free_port(false); + let mut url = format!("srt://127.0.0.1:{}?latency=300&streamid=%23!::r=room/owner,m=publish", px.addr.port()); + if let Some(p) = passphrase { + url.push_str(&format!("&passphrase={p}&pbkeylen={pbkeylen}")); + } + let _c = spawn(&slt, &["-q", &format!("udp://127.0.0.1:{udp_in}"), &url], false); + let mut link = timeout(STEP, l.accept()).await.expect("the reference caller connects").unwrap(); + assert_eq!(link.info.stream_id.as_deref(), Some("#!::r=room/owner,m=publish")); + assert_eq!(link.info.cipher_bits as usize, passphrase.map_or(0, |_| pbkeylen * 8)); + let (chunks, all) = program(); + tokio::spawn(feed_udp(chunks, format!("127.0.0.1:{udp_in}").parse().unwrap())); + let got = drain(&mut link, Duration::from_millis(1500)).await; + assert_eq!(got.len(), all.len(), "every byte"); + assert!(got == all, "byte for byte"); + let part = link.account.lock().unwrap().cut(1, false); + part.check().unwrap(); + assert_eq!(part.dropped, 0, "{part:?}"); + assert!(part.delivered > part.first_try && part.requests > 0, "losses found, requested and recovered: {part:?}"); + assert!(px.up.dropped.load(std::sync::atomic::Ordering::Relaxed) > 0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "runs the reference SRT tool: set WAI_SRT_LIVE_TRANSMIT"] +async fn srt_reference_caller_into_this_listener() { + srt_reference_caller(None, 16).await; + srt_reference_caller(Some("0123456789abcdef"), 16).await; + srt_reference_caller(Some("0123456789abcdef"), 32).await; +} + +async fn srt_this_caller(passphrase: Option<&str>, pbkeylen: usize) { + let slt = tool("WAI_SRT_LIVE_TRANSMIT"); + let port = free_port(false); + let mut url = format!("srt://:{port}?mode=listener&latency=300"); + if let Some(p) = passphrase { + url.push_str(&format!("&passphrase={p}")); + } + // `-a no`: the reference listener exits when the caller ends the connection, and so + // flushes what it wrote to its standard output (killed, it would lose its buffer). + let mut c = spawn(&slt, &["-q", "-a", "no", &url, "file://con"], true); + let mut out = c.0.stdout.take().unwrap(); + let reader = std::thread::spawn(move || { + let mut b = Vec::new(); + std::io::Read::read_to_end(&mut out, &mut b).unwrap(); + b + }); + tokio::time::sleep(Duration::from_millis(500)).await; + let (up, down) = lossy(); + let px = Proxy::start(format!("127.0.0.1:{port}").parse().unwrap(), up, down, 22).await.unwrap(); + let cfg = CallConfig { latency_ms: 300, passphrase: passphrase.map(str::to_string), keylen: pbkeylen, ..CallConfig::default() }; + let mut link = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().expect("the reference listener admits the call"); + let (chunks, all) = program(); + joule_contrib::cli::pace(&link, &chunks, 1500).await.unwrap(); + let sent = link.sent.lock().unwrap().clone(); + link.close(); + let _ = timeout(Duration::from_secs(3), link.wait_closed()).await; + let got = tokio::task::spawn_blocking(move || reader.join().unwrap()).await.unwrap(); + drop(c); + if got != all { + // Which of the chunks did not arrive, by content. + let missing: Vec = chunks.iter().enumerate().filter(|(_, c)| !got.windows(c.1.len()).step_by(4).any(|w| w == &c.1[..])).map(|(i, _)| i).collect(); + eprintln!("missing chunks of {}: {missing:?}", chunks.len()); + } + assert_eq!(got.len(), all.len(), "every byte reached the reference listener: {sent:?}"); + assert!(got == all, "byte for byte"); + assert!(sent.retransmitted > 0 && sent.requests > 0, "the reference listener's requests were answered: {sent:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "runs the reference SRT tool: set WAI_SRT_LIVE_TRANSMIT"] +async fn this_caller_into_the_srt_reference_listener() { + srt_this_caller(None, 16).await; + srt_this_caller(Some("0123456789abcdef"), 24).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "runs the reference SRT tool: set WAI_SRT_LIVE_TRANSMIT"] +async fn the_srt_reference_caller_with_a_wrong_passphrase_is_refused() { + let slt = tool("WAI_SRT_LIVE_TRANSMIT"); + let adm = |_: &ConnRequest| Ok(Admitted { passphrase: Some("the listener's passphrase".into()), role: Role::Receive, latency_ms: None, tag: 0 }); + let mut l = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap(); + let url = format!("srt://127.0.0.1:{}?passphrase=another-passphrase", l.addr.port()); + let _c = spawn(&slt, &["-q", "-a", "no", &format!("udp://127.0.0.1:{}", free_port(false)), &url], false); + assert!(timeout(Duration::from_secs(4), l.accept()).await.is_err(), "no link"); + assert!(l.refused.load(std::sync::atomic::Ordering::Relaxed) > 0, "the call was refused"); +} + +// ── RIST ─────────────────────────────────────────────────────────────────────────── + +#[cfg(feature = "rist")] +mod rist_tools { + use super::*; + use joule_contrib::rist::{self, recv::RecvConfig, Profile, RistListener, RistListenerConfig, SendConfig}; + + async fn reference_sender(profile: Profile, secret: Option<&str>, aes: u16, loss: bool) { + let rs = tool("WAI_RISTSENDER"); + let mut l = RistListener::bind(RistListenerConfig { + profile, + bind_ip: "127.0.0.1".parse().unwrap(), + port: 0, + recv: RecvConfig { buffer_us: 1_000_000, ..RecvConfig::default() }, + secret: secret.map(str::to_string), + expect_cname: Some("tok".into()), + tag: 0, + }) + .await + .unwrap(); + let (up, down) = lossy(); + let px = if loss { Some(Proxy::start(l.addr, up, down, 31).await.unwrap()) } else { None }; + let target = px.as_ref().map_or(l.addr, |p| p.addr); + let udp_in = free_port(false); + let mut url = format!("rist://127.0.0.1:{}?cname=tok&buffer=1000", target.port()); + if let Some(s) = secret { + url.push_str(&format!("&secret={s}&aes-type={aes}")); + } + let p = if profile == Profile::Main { "1" } else { "0" }; + let _c = spawn(&rs, &["-i", &format!("udp://@127.0.0.1:{udp_in}"), "-o", &url, "-p", p, "-v", "2"], false); + tokio::time::sleep(Duration::from_millis(1000)).await; + let (chunks, all) = program(); + tokio::spawn(feed_udp(chunks, format!("127.0.0.1:{udp_in}").parse().unwrap())); + let mut link = timeout(STEP, l.accept()).await.expect("the reference sender's flow").unwrap(); + assert_eq!(link.info.cname.as_deref(), Some("tok")); + let got = drain(&mut link, Duration::from_millis(2500)).await; + assert_eq!(got.len(), all.len()); + assert!(got == all, "byte for byte"); + let part = link.account.lock().unwrap().cut(1, false); + part.check().unwrap(); + if loss { + assert_eq!(part.dropped, 0, "{part:?}"); + assert!(part.delivered > part.first_try, "losses recovered by the reference sender's retransmissions: {part:?}"); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[ignore = "runs the reference RIST tools: set WAI_RISTSENDER"] + async fn rist_reference_sender_into_this_receiver() { + reference_sender(Profile::Simple, None, 0, false).await; + reference_sender(Profile::Main, None, 0, true).await; + reference_sender(Profile::Main, Some("0123456789abcdef"), 128, true).await; + reference_sender(Profile::Main, Some("0123456789abcdef"), 256, false).await; + } + + async fn this_sender(profile: Profile, secret: Option<&str>, aes: u16, loss: bool) { + let rr = tool("WAI_RISTRECEIVER"); + let port = free_port(true); + let out_port = free_port(false); + let mut url = format!("rist://@127.0.0.1:{port}?buffer=1000"); + if let Some(s) = secret { + url.push_str(&format!("&secret={s}&aes-type={aes}")); + } + let p = if profile == Profile::Main { "1" } else { "0" }; + let cap = tokio::spawn(capture_udp(out_port, Duration::from_millis(3000))); + let _c = spawn(&rr, &["-i", &url, "-o", &format!("udp://127.0.0.1:{out_port}"), "-p", p, "-v", "2"], false); + tokio::time::sleep(Duration::from_millis(700)).await; + let cfg = SendConfig { profile, cname: "tok".into(), secret: secret.map(str::to_string), aes_256: aes == 256, buffer_ms: 1000, ..SendConfig::default() }; + // The Main Profile's one port can be reached through a lossy path. + let (up, down) = lossy(); + let px = if loss { Some(Proxy::start(format!("127.0.0.1:{port}").parse().unwrap(), up, down, 32).await.unwrap()) } else { None }; + let target = px.as_ref().map_or_else(|| format!("127.0.0.1:{port}").parse().unwrap(), |p| p.addr); + let link = rist::send(target, cfg).await.unwrap(); + let (chunks, all) = program(); + joule_contrib::cli::pace(&link, &chunks, 2000).await.unwrap(); + let sent = link.sent.lock().unwrap().clone(); + link.close(); + let got = cap.await.unwrap(); + assert_eq!(got.len(), all.len(), "every byte reached the reference receiver: {sent:?}"); + assert!(got == all, "byte for byte"); + if loss { + assert!(sent.requests > 0 && sent.retransmitted > 0, "the reference receiver's requests were answered: {sent:?}"); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[ignore = "runs the reference RIST tools: set WAI_RISTRECEIVER"] + async fn this_sender_into_the_rist_reference_receiver() { + this_sender(Profile::Simple, None, 0, false).await; + this_sender(Profile::Main, None, 0, true).await; + this_sender(Profile::Main, Some("0123456789abcdef"), 256, true).await; + } +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/tests/rist_link.rs b/joule-code/joule-code-rs/crates/joule-contrib/tests/rist_link.rs new file mode 100644 index 000000000..b52bd66ab --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/tests/rist_link.rs @@ -0,0 +1,178 @@ +//! RIST on loopback, this crate's sender against its receiver: the Simple Profile on P and +//! P+1 with range and bitmask requests through proxies that drop datagrams; the Main +//! Profile's tunnel in the clear and under PSK (128 and 256), in the VSF and the legacy +//! formats, with loss; a CNAME that is not the expected one; a wrong passphrase. + +#![cfg(feature = "rist")] + +use joule_contrib::link::Link; +use joule_contrib::lossy::{Impair, Proxy}; +use joule_contrib::rist::recv::RecvConfig; +use joule_contrib::rist::{self, NackKind, Profile, RistListener, RistListenerConfig, SendConfig}; +use std::net::SocketAddr; +use std::sync::atomic::Ordering; +use std::time::Duration; +use tokio::time::timeout; + +const STEP: Duration = Duration::from_secs(20); + +fn payload(i: u64) -> Vec { + let mut p = vec![0u8; 1316]; + for (k, c) in p.chunks_mut(188).enumerate() { + c[0] = 0x47; + for (j, b) in c.iter_mut().enumerate().skip(1) { + *b = ((i * 11 + k as u64 * 3 + j as u64) % 251) as u8; + } + } + p +} + +async fn carry(tx: &Link, rx: &mut Link, n: u64, pps: u64) -> Vec> { + let send = tx.send.clone().unwrap(); + let t = tokio::spawn(async move { + let start = tokio::time::Instant::now(); + // Ten more than are counted: a receiver cannot know of a packet lost after the + // last it receives. + for i in 0..n + 10 { + tokio::time::sleep_until(start + Duration::from_micros(i * 1_000_000 / pps)).await; + if send.send(payload(i)).await.is_err() { + return; + } + } + }); + let mut got = Vec::new(); + while got.len() < n as usize + 10 { + match timeout(Duration::from_secs(5), rx.data.recv()).await { + Ok(Some(p)) => got.push(p), + _ => break, + } + } + t.await.unwrap(); + got.truncate(n as usize); + got +} + +fn listener_cfg(profile: Profile, secret: Option<&str>, cname: Option<&str>, nack: NackKind) -> RistListenerConfig { + RistListenerConfig { + profile, + bind_ip: "127.0.0.1".parse().unwrap(), + port: 0, + recv: RecvConfig { buffer_us: 600_000, nack, ..RecvConfig::default() }, + secret: secret.map(str::to_string), + expect_cname: cname.map(str::to_string), + tag: 0, + } +} + +async fn simple(nack: NackKind, seed: u64) { + let mut l = RistListener::bind(listener_cfg(Profile::Simple, None, Some("token-1"), nack)).await.unwrap(); + let p = l.addr; + // Two proxies, one per port. The sender's RTCP must reach P + 1, so the RTCP proxy + // listens on an address whose port is the RTP proxy's plus one. + let imp = Impair { loss_ppm: 40_000, drop_index: &[30, 31, 32, 33], spare_first: 8, ..Impair::default() }; + let (rtp_px, rtcp_px) = proxy_pair(p, imp, Impair { loss_ppm: 40_000, ..Impair::default() }, seed).await; + let tx = rist::send(rtp_px.addr, SendConfig { cname: "token-1".into(), buffer_ms: 1000, ..SendConfig::default() }).await.unwrap(); + let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap(); + assert_eq!(rx.info.cname.as_deref(), Some("token-1")); + let got = carry(&tx, &mut rx, 800, 400).await; + assert!(rtp_px.up.dropped.load(Ordering::Relaxed) > 10); + tokio::time::sleep(Duration::from_millis(900)).await; + let part = rx.account.lock().unwrap().cut(1, false); + part.check().unwrap(); + assert_eq!(got.len(), 800, "{part:?}"); + assert!(got.iter().enumerate().all(|(i, q)| *q == payload(i as u64))); + assert!(part.delivered - part.first_try >= 4 && part.requests > 0 && part.retransmitted > 0, "{part:?}"); + assert!(rx.rtt() > 0, "the RTT echo measured the round trip"); + let s = tx.sent.lock().unwrap().clone(); + assert!(s.retransmitted > 0); + rtp_px.stop(); + rtcp_px.stop(); +} + +/// Proxies to P and P + 1 on two ports one apart, the first even, as a Simple Profile +/// sender addresses them. +async fn proxy_pair(p: SocketAddr, up: Impair, down: Impair, seed: u64) -> (Proxy, Proxy) { + for _ in 0..100 { + let probe = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let q = probe.local_addr().unwrap().port(); + drop(probe); + let q = if q.is_multiple_of(2) { q } else { q + 1 }; + if q >= 65_534 { + continue; + } + let Ok(a) = Proxy::start_on(SocketAddr::new(p.ip(), q), p, up, down, seed).await else { continue }; + match Proxy::start_on(SocketAddr::new(p.ip(), q + 1), SocketAddr::new(p.ip(), p.port() + 1), down, down, seed + 1).await { + Ok(b) => return (a, b), + Err(_) => a.stop(), + } + } + panic!("no adjacent proxy ports"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_simple_profile_recovers_with_range_requests() { + simple(NackKind::Range, 3).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_simple_profile_recovers_with_bitmask_requests() { + simple(NackKind::Bitmask, 4).await; +} + +async fn main_profile(secret: Option<&str>, aes_256: bool, vsf: bool, loss: u32) { + let mut l = RistListener::bind(listener_cfg(Profile::Main, secret, Some("tunnel-token"), NackKind::Range)).await.unwrap(); + let imp = Impair { loss_ppm: loss, spare_first: 16, ..Impair::default() }; + let px = Proxy::start(l.addr, imp, imp, 17).await.unwrap(); + let cfg = SendConfig { profile: Profile::Main, cname: "tunnel-token".into(), secret: secret.map(str::to_string), aes_256, vsf, buffer_ms: 1000, ..SendConfig::default() }; + let tx = rist::send(px.addr, cfg).await.unwrap(); + let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap(); + assert_eq!(rx.info.cipher_bits, if secret.is_some() { if aes_256 { 256 } else { 128 } } else { 0 }); + let got = carry(&tx, &mut rx, 600, 400).await; + tokio::time::sleep(Duration::from_millis(900)).await; + let part = rx.account.lock().unwrap().cut(1, false); + part.check().unwrap(); + assert_eq!(got.len(), 600, "{part:?}"); + assert!(got.iter().enumerate().all(|(i, q)| *q == payload(i as u64))); + if loss > 0 { + assert!(part.delivered > part.first_try, "losses were recovered: {part:?}"); + } + px.stop(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_main_profile_in_the_clear() { + main_profile(None, false, true, 30_000).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_main_profile_under_psk_128_and_256() { + main_profile(Some("Reliable Internet Stream Transport"), false, true, 30_000).await; + main_profile(Some("another tunnel passphrase"), true, true, 0).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_main_profile_in_the_legacy_format() { + main_profile(Some("legacy formats"), false, false, 20_000).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_wrong_cname_and_the_wrong_passphrase_are_refused() { + let mut l = RistListener::bind(listener_cfg(Profile::Main, Some("the tunnel's passphrase"), Some("tok"), NackKind::Range)).await.unwrap(); + let bad = rist::send(l.addr, SendConfig { profile: Profile::Main, cname: "tok".into(), secret: Some("not the passphrase".into()), ..SendConfig::default() }).await.unwrap(); + let send = bad.send.clone().unwrap(); + for i in 0..50 { + send.send(payload(i)).await.unwrap(); + } + assert!(timeout(Duration::from_millis(1500), l.accept()).await.is_err(), "no flow under another passphrase"); + assert!(l.refused.undecryptable.load(Ordering::Relaxed) > 0); + bad.close(); + + let mut s = RistListener::bind(listener_cfg(Profile::Simple, None, Some("tok"), NackKind::Range)).await.unwrap(); + let other = rist::send(s.addr, SendConfig { cname: "another".into(), ..SendConfig::default() }).await.unwrap(); + let send = other.send.clone().unwrap(); + for i in 0..20 { + send.send(payload(i)).await.unwrap(); + } + assert!(timeout(Duration::from_millis(1500), s.accept()).await.is_err(), "no flow under another CNAME"); + assert!(s.refused.cname.load(Ordering::Relaxed) > 0); +} diff --git a/joule-code/joule-code-rs/crates/joule-contrib/tests/srt_link.rs b/joule-code/joule-code-rs/crates/joule-contrib/tests/srt_link.rs new file mode 100644 index 000000000..afe55f202 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-contrib/tests/srt_link.rs @@ -0,0 +1,161 @@ +//! SRT on loopback, this crate's caller against its listener: every payload delivered in +//! order and whole, with no loss and through a proxy that drops datagrams each way; the +//! account of every sequence number; encryption, a wrong passphrase, a missing one, the +//! stream ID reaching the admission and its refusal codes; and the latency window: a packet +//! is not delivered before it. + +use joule_contrib::lossy::{Impair, Proxy}; +use joule_contrib::srt::packet::rej; +use joule_contrib::srt::{self, conn::CallError, Admitted, CallConfig, ConnRequest, ListenerConfig, Role}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::time::timeout; + +const STEP: Duration = Duration::from_secs(20); + +fn payload(i: u64) -> Vec { + let mut p = vec![0u8; 1316]; + for (k, c) in p.chunks_mut(188).enumerate() { + c[0] = 0x47; + for (j, b) in c.iter_mut().enumerate().skip(1) { + *b = ((i * 7 + k as u64 * 13 + j as u64) % 251) as u8; + } + } + p +} + +async fn listener(pass: Option<&str>, latency: u16, seen: Arc>>) -> srt::Listener { + let pass = pass.map(str::to_string); + let adm = move |r: &ConnRequest| { + seen.lock().unwrap().push(r.clone()); + match r.stream_id.as_deref() { + Some("#!::m=request") => Err(rej::BAD_MODE), + Some("#!::r=nobody,m=publish") => Err(rej::NOTFOUND), + _ => Ok(Admitted { passphrase: pass.clone(), role: Role::Receive, latency_ms: Some(latency), tag: 42 }), + } + }; + srt::Listener::bind(ListenerConfig { latency_ms: latency, ..ListenerConfig::default() }, Arc::new(adm)).await.unwrap() +} + +/// Send `n` payloads at `pps` packets a second; receive them all; return what arrived. +async fn carry(tx: &joule_contrib::link::Link, rx: &mut joule_contrib::link::Link, n: u64, pps: u64) -> Vec> { + let send = tx.send.clone().unwrap(); + let t = tokio::spawn(async move { + let start = tokio::time::Instant::now(); + // Ten more than are counted: a receiver cannot know of a packet lost after the + // last it receives. + for i in 0..n + 10 { + tokio::time::sleep_until(start + Duration::from_micros(i * 1_000_000 / pps)).await; + send.send(payload(i)).await.unwrap(); + } + }); + let mut got = Vec::new(); + while got.len() < n as usize + 10 { + match timeout(Duration::from_secs(5), rx.data.recv()).await { + Ok(Some(p)) => got.push(p), + _ => break, + } + } + t.await.unwrap(); + got.truncate(n as usize); + got +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn every_payload_arrives_in_order_and_is_accounted_for() { + let seen = Arc::new(Mutex::new(Vec::new())); + let mut l = listener(None, 120, seen.clone()).await; + let cfg = CallConfig { stream_id: Some("#!::r=room/owner,m=publish,s=abc".into()), latency_ms: 80, ..CallConfig::default() }; + let tx = timeout(STEP, srt::call(l.addr, cfg)).await.unwrap().unwrap(); + let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap(); + assert_eq!(rx.info.stream_id.as_deref(), Some("#!::r=room/owner,m=publish,s=abc")); + assert_eq!(rx.info.tag, 42); + assert_eq!(rx.info.latency_us, 120_000, "the larger of the two ends' latencies"); + assert_eq!(seen.lock().unwrap()[0].latency_ms, 80); + let got = carry(&tx, &mut rx, 400, 500).await; + assert_eq!(got.len(), 400); + assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64)), "every payload whole and in order"); + tokio::time::sleep(Duration::from_millis(300)).await; + let part = rx.account.lock().unwrap().cut(1, false); + part.check().unwrap(); + assert_eq!((part.seq_count, part.first_try, part.dropped), (410, 410, 0), "the 400 and the ten after them"); + assert!(part.rtt.samples > 0, "ACK/ACKACK pairs measured the RTT"); + tx.close(); + let mut rx = rx; + assert_eq!(timeout(STEP, rx.wait_closed()).await.unwrap(), "peer_shutdown"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn losses_each_way_are_recovered_within_the_window() { + let seen = Arc::new(Mutex::new(Vec::new())); + let mut l = listener(Some("correct horse battery"), 300, seen).await; + // 3% of datagrams dropped toward the listener, 3% back (NAKs and ACKs among them), and + // datagrams 50 to 54 in a row. + let up = Impair { loss_ppm: 30_000, drop_index: &[50, 51, 52, 53, 54], ..Impair::default() }; + let down = Impair { loss_ppm: 30_000, ..Impair::default() }; + let px = Proxy::start(l.addr, up, down, 0x5eed).await.unwrap(); + let cfg = CallConfig { passphrase: Some("correct horse battery".into()), keylen: 32, latency_ms: 300, ..CallConfig::default() }; + let tx = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().unwrap(); + let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap(); + assert_eq!(rx.info.cipher_bits, 256); + let got = carry(&tx, &mut rx, 1000, 500).await; + assert!(px.up.dropped.load(std::sync::atomic::Ordering::Relaxed) > 20, "the proxy dropped datagrams"); + tokio::time::sleep(Duration::from_millis(500)).await; + let part = rx.account.lock().unwrap().cut(1, false); + part.check().unwrap(); + assert!(part.delivered >= got.len() as u64); + // Everything was recovered: the window is many round trips on loopback. + assert_eq!(part.dropped, 0, "{part:?}"); + assert_eq!(got.len(), 1000); + assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64))); + assert!(part.first_try < 1000 && part.delivered - part.first_try >= 5, "losses were found and recovered: {part:?}"); + assert!(part.requests > 0 && part.retransmitted > 0); + assert!(part.runs.iter().any(|r| r.count >= 5), "the burst of five is one run"); + assert!(part.recovery.max < 300_000 && part.margin_min > 0); + px.stop(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_packet_is_held_for_the_latency_window() { + let seen = Arc::new(Mutex::new(Vec::new())); + let mut l = listener(None, 400, seen).await; + let tx = srt::call(l.addr, CallConfig { latency_ms: 400, ..CallConfig::default() }).await.unwrap(); + let mut rx = l.accept().await.unwrap(); + let t = Instant::now(); + tx.send.clone().unwrap().send(payload(0)).await.unwrap(); + let p = timeout(STEP, rx.data.recv()).await.unwrap().unwrap(); + let held = t.elapsed(); + assert_eq!(p, payload(0)); + assert!(held >= Duration::from_millis(380) && held < Duration::from_millis(900), "delivered at its TSBPD time: {held:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refusals_carry_their_codes() { + let seen = Arc::new(Mutex::new(Vec::new())); + let l = listener(Some("the right passphrase"), 120, seen.clone()).await; + let call = |pass: Option<&str>, sid: Option<&str>| { + let cfg = CallConfig { passphrase: pass.map(str::to_string), stream_id: sid.map(str::to_string), connect_timeout_ms: 3_000, ..CallConfig::default() }; + srt::call(l.addr, cfg) + }; + assert_eq!(call(Some("a wrong passphrase"), None).await.err(), Some(CallError::Rejected(rej::BADSECRET))); + assert_eq!(call(None, None).await.err(), Some(CallError::Rejected(rej::UNSECURE))); + assert_eq!(call(Some("the right passphrase"), Some("#!::m=request")).await.err(), Some(CallError::Rejected(rej::BAD_MODE))); + assert_eq!(call(Some("the right passphrase"), Some("#!::r=nobody,m=publish")).await.err(), Some(CallError::Rejected(rej::NOTFOUND))); + assert!(call(Some("the right passphrase"), None).await.is_ok()); + assert_eq!(l.refused.load(std::sync::atomic::Ordering::Relaxed), 4); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_listener_that_sends_and_a_caller_that_receives() { + let adm = |_: &ConnRequest| Ok(Admitted { passphrase: Some("both ways round".into()), role: Role::Send, latency_ms: None, tag: 0 }); + let mut l = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap(); + let px = Proxy::start(l.addr, Impair { loss_ppm: 20_000, ..Impair::default() }, Impair { loss_ppm: 20_000, ..Impair::default() }, 99).await.unwrap(); + let cfg = CallConfig { passphrase: Some("both ways round".into()), role: Role::Receive, latency_ms: 250, ..CallConfig::default() }; + let mut rx = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().unwrap(); + let tx = timeout(STEP, l.accept()).await.unwrap().unwrap(); + let got = carry(&tx, &mut rx, 600, 400).await; + assert_eq!(got.len(), 600); + assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64))); + let s = tx.sent.lock().unwrap().clone(); + assert!(s.retransmitted > 0 && s.requests > 0, "{s:?}"); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml index d016f2c17..2986cdb87 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "joule-moq-ingest" version = "0.1.0" -description = "A WebRTC ingest and egress gateway for WAI rooms: WHIP (RFC 9725) into MoQ room tracks, where it is the track's publisher and signs every group's receipts as the first hop, and WHEP (draft-ietf-wish-whep-05) out of them, verifying every object's receipt before it forwards it." +description = "An ingest and egress gateway for WAI rooms: WHIP (RFC 9725) and Enhanced RTMP v2 into MoQ room tracks, where it is the tracks' publisher and signs every group's receipts as the first hop; WHEP (draft-ietf-wish-whep-05) out of them, verifying every object's receipt before it forwards it; and SCTE 104 automation translated to SCTE 35 and signed as the room's cues." edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -14,9 +14,22 @@ path = "src/lib.rs" name = "joule-moq-ingest" path = "src/main.rs" +# The link records' conformance corpus, wai/live-conformance/links/. +[[bin]] +name = "joule-moq-ingest-link-vectors" +path = "src/bin/link_vectors.rs" + +[features] +default = [] +# RIST (VSF TR-06-1, TR-06-2) contribution, from joule-contrib. Off by default: see +# joule-contrib's README, "Patents". +rist = ["joule-contrib/rist"] + [dependencies] joule-moq = { path = "../joule-moq" } joule-moq-relay = { path = "../joule-moq-relay" } +# SRT and RIST, and the MPEG-TS demultiplexer (this workspace, Apache-2.0). +joule-contrib = { path = "../joule-contrib" } # WebRTC, sans-IO (MIT OR Apache-2.0), with its pure-Rust crypto backend: DTLS by its own # `dimpl`, SRTP and the rest on RustCrypto. No C library is linked. str0m = { version = "=0.24.1", default-features = false, features = ["rust-crypto"] } @@ -26,6 +39,8 @@ hyper-util = { version = "=0.1.20", default-features = false, features = http-body-util = "=0.1.5" quinn = "0.11" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# RTMPS: the Enhanced RTMP listener over TLS, on the same rustls and ring (MIT OR Apache-2.0). +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } bytes = "1" futures = "0.3" @@ -43,6 +58,14 @@ base64 = "0.22" thiserror = "2" [dev-dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +# `process`: tests/ertmp_ffmpeg.rs runs a local encoder as a test client. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process"] } # The tests' HTTP client. hyper = { version = "=1.11.1", features = ["client", "http1"] } + +# Contribution over RIST needs the feature that carries it. +[[test]] +name = "contrib_rist" +path = "tests/contrib_rist.rs" +required-features = ["rist"] + diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md b/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md index cb7607625..494439cc5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/README.md @@ -1,12 +1,17 @@ # joule-moq-ingest -A gateway between WebRTC and the MoQ tracks of a WAI room. A standard WebRTC encoder -publishes into a room over **WHIP** (RFC 9725); the gateway becomes its tracks' MoQ -publisher at the relay and signs every group's receipts as the first hop. A standard -WebRTC player watches a room's tracks over **WHEP** (`draft-ietf-wish-whep-05`); the -gateway subscribes at the relay and verifies every object's receipt before it hands the -frame on. The mapping it implements is WAI's `extensions/live-ingest.md`, which also maps -Enhanced RTMP v2 multitrack and SRT (specified, not implemented here). +A gateway between contribution protocols and the MoQ tracks of a WAI room. A standard +WebRTC encoder publishes into a room over **WHIP** (RFC 9725), and an RTMP encoder over +**Enhanced RTMP v2** (`v2-2026-01-31-r2`), single-track or multitrack, over TCP or TLS; the +gateway becomes their tracks' MoQ publisher at the relay and signs every group's receipts as +the first hop. A standard WebRTC player watches a room's tracks over **WHEP** +(`draft-ietf-wish-whep-05`); the gateway subscribes at the relay and verifies every object's +receipt before it hands the frame on. A broadcast automation system drives the gateway's +**SCTE 104** injector, which translates its requests to SCTE 35 and signs them as the room's +cues. It also takes contribution over **SRT** and **RIST** into the same tracks +(`extensions/live-contribution.md`, below), and signs each such link's record of loss, +recovery and latency. The mappings it implements are WAI's `extensions/live-ingest.md`, +`extensions/cue-injection.md` and `extensions/live-contribution.md`. It is a library (`GatewayServer`, `Gateway`) and a binary, `joule-moq-ingest`. The relay is `joule-moq-relay`; the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport-22`. @@ -19,6 +24,7 @@ is `joule-moq-relay`; the MOQT codec is `joule-moq`'s, `draft-ietf-moq-transport | `/whip/s/` | `PATCH` an `application/trickle-ice-sdpfrag`: trickle ICE (`204`) or an ICE restart (`200`, a fragment, a new `ETag`); `DELETE` (`200`) ends the session and its tracks | | `/whep//[?layer=]` | `POST` an offer for the owner's tracks: `201`, or `409` with `Retry-After` while the owner is not live. `HEAD` answers with `Link: ; rel="profile"` (WHEP §4.2) | | `/whep/s/` | `PATCH`, `DELETE`, as for WHIP | +| `/rtmp` | `POST`: `201`, `{"expires_in", "stream_key", "url"}`: a one-time stream key for an Enhanced RTMP publish under the grant | Every request but `OPTIONS` carries `Authorization: Bearer `. **The token is a room grant**: a WAI `WRG1` record (`live-rooms.md` §4), base64url without padding. A grant is @@ -109,6 +115,53 @@ WebTransport or WebSocket paths, and verifies every object itself. A verified group is released when its receipts arrive, at the group's end, so a WHEP player is up to a video group behind the publisher. +## Enhanced RTMP into the room + +- **Admission.** An encoder has a server URL and a stream key, and cannot answer a + challenge, so the publisher first POSTs to `/rtmp` with its grant and a proof of + possession, admitted as a WHIP request is, and gets a stream key: 128 random bits, good for + one `publish` within `stream_key_ttl_s` (60 s). The `publish` command's stream name is the + key; the grant is admitted again when it is used. A refusal is `onStatus` + `NetStream.Publish.BadName` with the reason as its description. +- **The connection** (`rtmp::chunk`, `rtmp::amf0`, `rtmp::session`): the simple handshake, + the chunk stream with its extended timestamps, Set Chunk Size, Abort, acknowledgements and + pings, and the commands. The `connect` result declares `capsEx` 0x0F and the FourCCs it + carries. +- **Tracks** (`rtmp::tracks`, `rtmp::flv`): legacy AVC and AAC, and `avc1`, `hvc1`, `av01`, + `vp09`, `vp08`, `Opus`, `fLaC` and `mp4a`, through ModEx and multitrack bodies and aggregate + messages. Each RTMP track is bound to a room track at its first packet: under a grant with a + layered video profile, the layer its `onMetaData` entry's size and frame rate match; audio + track 0 only. Each frame is one object: Annex B with the record's parameter sets before a + key access unit that carries none, the AV1 framing with the sequence header, IVF for VP8 and + VP9, ADTS for AAC, a FLAC stream. `live.rtp` is the decode time in µs (`rate` 1 000 000) and + `live.cto` the composition offset; §7's mapping gives `t`. +- **RTMPS** on `rtmp.tls_listen` with a PEM chain and PKCS #8 key (tokio-rustls on the + workspace's rustls and ring). +- **Shutdown.** A publisher that declared `Reconnect` is sent + `NetConnection.Connect.ReconnectRequest` (with `rtmp.reconnect_url` as its `tcUrl`, if set), + its key is good once more, and the session ends when it leaves or after + `reconnect_grace_ms`. +- **Records.** Each publish ends with a session record (`protocol` `ertmp`) whose `detail` + counts what was read and not carried, by reason. + +## SCTE 104 into the room's cues + +`scte` listens on `injector.listen` (SCTE 104's port is 5167) for one automation system at a +time: `init_request`, `alive_request`, and `multiple_operation_message()`s of splice, +time-signal and splice-null requests with avail, DTMF, segmentation, time, tier and private +descriptor requests. Each is answered (`inject_response`, then `inject_complete_response` +once its cues are signed) with SCTE 104's result codes. `scte::scte104::translate` builds each +SCTE 35 section (`scte::scte35`) on the room's 90 kHz clock and the cues it yields; +`scte::Engine` holds each cue until its splice instant, applies cancels and early returns, +drops what live-ads' break rules would refuse, and signs the cue as a `cue` event of the +injector's principal after its injection record (`WIJ1`, a `meta` event carrying the message, +its arrival and the translation's parameters). The events go out on the principal's events +track at the relay, published from the injector's start. The injector's grant must name its +key and permit `cue` and `meta`. + +A relay whose door checks receipts names the room's media kinds in its rules +(`[room, "*", "audio"]`, `[room, "*", "video"]`): an events track carries no receipts. + ## Bounds Each is a field of `[limits]`, `[groups]`, `[timeline]` or `[whep]` in the configuration: @@ -123,6 +176,12 @@ Each is a field of `[limits]`, `[groups]`, `[timeline]` or `[whep]` in the confi | `audio_group_ms`, `max_video_group_ms` | 200, 2 000 | — | | `max_skew_ms` | 250 | the track is re-anchored | | `whep.hold_ms` | 5 000 | the group is dropped (`receipt_missing`) | +| `rtmp.max_sessions` | 64 | one more connection is closed at accept | +| `rtmp.max_message_bytes` | 8 MiB | the connection ends (`message_too_large`) | +| `rtmp.max_bitrate_kbps` over `bitrate_window_ms` | 40 000 over 2 000 ms | the publish ends (`bitrate_exceeded`) | +| `rtmp.connect_timeout_ms`, `rtmp.idle_timeout_ms` | 15 000, 20 000 | the connection ends | +| `rtmp.stream_key_ttl_s` | 60 | the key is refused (`stream_key_expired`) | +| `injector.max_pending` | 256 | a message is answered 124 | `gateway.example.toml` shows every field. It holds no secret: the relay token and the signing key are files, and a room is its public, signed descriptor. @@ -146,6 +205,33 @@ crate's tests take AV1 through it end to end), supports ICE lite and full ICE, I restarts, NACK and keyframe requests. Every crate new to the lockfile is MIT, Apache-2.0 or ISC. +## SRT and RIST + +| resource | methods | +|---|---| +| `/srt` | `POST` with the grant and its proof: `201`, a one-time session as JSON: its `stream_id`, `passphrase`, `pbkeylen`, the listener's `latency_ms`, `host` and `port`. With `{"call": "ip:port", "passphrase": …}`, the gateway calls the publisher's SRT listener, where `[contrib.srt] call_allow` lists it | +| `/rist` | `POST` with the grant and its proof, `{"profile": "simple"|"main"}`: `201`, the session's `port` (Simple: an even port and the one above), its `cname`, its `buffer_ms`, and for the Main Profile the PSK `passphrase` and its `aes` | +| `/srt/s/`, `/rist/s/` | `DELETE` ends the session, or one not yet connected; `GET`, `HEAD` | + +The SRT listener (`[contrib.srt] listen`) checks each caller's stream ID +(`#!::r=/,m=publish,s=`) against the sessions it gave, and refuses with +the codes of live-ingest §13 (1401, 1402, 1403, 1404, 1405, 1409) and the draft's (1010 for a +wrong passphrase, 1011 for none). A RIST session's port takes the first flow whose RTCP CNAME +is the session's. The link's transport stream is demultiplexed: the first H.264 or HEVC +stream and the first AAC stream go to the video and audio tracks as WHIP's frames do, one +access unit (one ADTS frame) an object, the PTS on the room clock; a PES missing a packet is +dropped whole. The catalog declares the owner's `link` track. + +**The link record** (`linkrec`, `WCL1`): every `[contrib] record_interval_ms` and when the +session ends, the gateway signs a part: the sequence numbers passed, each delivered at the +first try, recovered, dropped or outstanding, the loss runs, requests, retransmissions, round +trips and recovery times, chained to the part before and naming each media track's receipt +chain head. Each part goes out on the `link` track with its receipts, is kept +(`Gateway::link_records`) and is appended to `[summary] link_out_path`. The corpus +`wai/live-conformance/links/` is written by `joule-moq-ingest-link-vectors`. + +RIST is built only with the feature `rist` (`joule-contrib`'s README, "Patents"). + ## The session record, and live metering Each session ends with a record of what it carried, signed by the gateway's key @@ -171,10 +257,21 @@ All on loopback, every wait bounded, the relay checking every receipt at its doo | `auth_and_ice` | No token `401`; a token that is not a grant `401 invalid_token`; a viewer's grant `403 insufficient_scope`; a grant past its window, one of another room; `415`; `422`; WHEP without subscribe or above `max_layer` `403`; discovery and CORS. `PATCH` without `If-Match` `428`, stale `412`, trickle `204`, another principal's grant, its own key proved, `403`; a `PATCH` or `DELETE` presenting the grant without a proof `401 pop_missing`; an ICE restart (`If-Match: *`) `200` with new credentials and a new `ETag`, ICE connecting again, and every frame before and after it at the relay. | | `codecs` | VP8, VP9 and AV1 each through WHIP: every frame whole, one group per keyframe, the size each framing states. | | `room_corpus` | The room reader against `wai/live-conformance/rooms/`, and its six HTTP proofs of possession through the gateway's proof step, each accepted proof also refused as a replay. | +| `ertmp` | A publisher of our own over RTMP and RTMPS: legacy AVC and AAC; enhanced HEVC with composition offsets and Opus; AV1 with FLAC and VP9 with AAC, a ModEx nanosecond offset; multitrack layers bound by the grant's profile; every refusal of a stream key; the reconnect request at shutdown. Every frame reaches a relay subscriber in its payload framing, at its mapped time, every receipt verified. | +| `scte104_injection` | An automation system of our own over TCP: init, alive, a second connection refused 110, splices with and without pre-roll, a cancel, time signals, refusals with their result codes; the chain verifies and every cue is its message's translation; a relay subscriber reads the chain from the events track. | +| `conformance_corpus` | `wai/live-conformance/ertmp/` and `wai/live-conformance/injection/` regenerate byte for byte; the texts' reason tables list the codes reported. | +| `ertmp_ffmpeg` (ignored; `--ignored` where a local encoder is present) | A local encoder as the test client: AVC with B-frames and AAC, HEVC and AVC on the platform's media engine, HEVC with Opus, AV1 with FLAC, VP9 with Opus; every frame decoded from what the relay carries. | +| `contrib_srt` | An SRT publisher, through a path that drops datagrams each way, into the gateway: a relay subscriber gets every H.264 access unit and AAC frame, every receipt verified; the link record's parts verify as a series, show the losses recovered and name each track's receipt-chain head as the subscriber found it, and are on the link track. Every listener refusal with its code; the gateway calling an encoder's listener with HEVC; 20 Mbit/s with groups of megabytes through the relay's door. | +| `contrib_rist` (feature `rist`) | A RIST Simple Profile flow and a Main Profile tunnel under PSK into the gateway, received whole; another CNAME opens no session. | +| `link_corpus` | Every vector of `wai/live-conformance/links/` with its stated outcome. | +| `contrib_interop` (ignored) | The reference SRT caller and RIST sender into the gateway, with an encoder's H.264, HEVC and AAC through lossy paths (tools named by `WAI_SRT_LIVE_TRANSMIT`, `WAI_RISTSENDER`, `WAI_FFMPEG`, `WAI_FFPROBE`). | +| `contrib_bench` (ignored) | The gateway's throughput and energy on an SRT link, the sender in a process of its own. | ## Not implemented -E-RTMP v2 and SRT ingest (`live-ingest.md` §12, §13: mapped only); a WHEP counter-offer; +`vvc1` over Enhanced RTMP; a stream key another gateway accepts after a reconnect request; +SCTE 104 in a video signal's ancillary data (`cue-injection.md` §10); SRT and RIST egress; a +WHEP counter-offer; a player's keyframe request carried upstream; publishing into a slot; simulcast within one WHIP session; STUN and TURN in `Link` headers; reading the key log itself (revocations are the list a deployment configures). diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/examples/ingest_energy.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/examples/ingest_energy.rs new file mode 100644 index 000000000..bb53b193f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/examples/ingest_energy.rs @@ -0,0 +1,88 @@ +//! The two processes an ingest energy measurement reads: a relay, and a gateway that +//! publishes to it, each its own process so that a reader of per-process energy (WAI +//! energy-measurement §9.2, `macos-task`) reads one hop alone. +//! +//! ```text +//! ingest_energy relay # prints RELAY +//! ingest_energy gateway # prints PID, then a stream key URL per line read +//! ``` +//! +//! The room, its authority, the publisher and the gateway's key are fixed seeds, so both +//! processes hold the same room. The gateway issues each stream key itself (the `/rtmp` +//! resource's admission is not what is measured) and prints `RTMP rtmp://…/live/` for +//! each line on its standard input; it stops at the end of its input. Every listener is +//! loopback. + +use ed25519_dalek::SigningKey; +use joule_moq_ingest::room::{build, Room}; +use joule_moq_ingest::{GatewayConfig, GatewayServer}; +use joule_moq_relay::{RelayConfig, RelayServer}; +use std::io::BufRead; +use std::sync::Arc; + +const T0: u64 = 1_790_000_000_000_000; + +fn keys() -> (SigningKey, SigningKey, SigningKey) { + (SigningKey::from_bytes(&[0x51; 32]), SigningKey::from_bytes(&[0x52; 32]), SigningKey::from_bytes(&[0x53; 32])) +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt().with_writer(std::io::stderr).with_max_level(tracing::Level::INFO).with_target(false).init(); + let args: Vec = std::env::args().skip(1).collect(); + let (authority, publisher, gateway_key) = keys(); + let descriptor = build::room(&authority, T0); + let room = descriptor.room_id(); + match args.first().map(String::as_str) { + Some("relay") => { + let mut rc = RelayConfig::loopback(); + // The door holds the room's media tracks to their receipts under the gateway's key. + rc.receipts.door = ["audio", "video"] + .iter() + .map(|k| joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room), "*".into(), k.to_string()], trust: vec![hex::encode(gateway_key.verifying_key().to_bytes())], trust_field: None, hold_ms: 10_000 }) + .collect(); + let relay = RelayServer::start(rc).await.expect("the relay starts"); + println!("RELAY {} {}", relay.quic_addr.unwrap(), hex::encode(&relay.identity.chain[0])); + println!("PID {}", std::process::id()); + let _ = tokio::task::spawn_blocking(|| std::io::stdin().lock().lines().count()).await; + relay.shutdown().await; + } + Some("gateway") => { + let addr = args.get(1).expect("the relay's address").parse().expect("an address"); + let cert = hex::decode(args.get(2).expect("the relay's certificate")).expect("hex"); + let mut gc = GatewayConfig::loopback(addr); + gc.relay.trust_der = vec![cert]; + gc.signing.key_seed = Some(gateway_key.to_bytes()); + gc.rooms = vec![joule_moq_ingest::config::RoomConfig { descriptor_path: None, revoked: Vec::new(), descriptor: Some(descriptor.to_bytes()) }]; + gc.rtmp.listen = Some("127.0.0.1:0".parse().unwrap()); + gc.rtmp.stream_key_ttl_s = 3600; + let server = GatewayServer::start(gc).await.expect("the gateway starts"); + let rtmp = server.rtmp_addr.unwrap(); + println!("PID {}", std::process::id()); + let gw = server.gateway.clone(); + let room_arc: Arc = gw.rooms.get(&room).unwrap().clone(); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + std::thread::spawn(move || { + for _ in std::io::stdin().lock().lines() { + if tx.send(()).is_err() { + break; + } + } + }); + while rx.recv().await.is_some() { + let g = build::grant(&descriptor, &authority, &publisher.verifying_key().to_bytes(), 0, now - 60, now + 86_000); + let key = gw.issue_stream_key(room_arc.clone(), g.to_bytes()); + println!("RTMP rtmp://{rtmp}/live/{key}"); + } + for r in gw.records() { + eprintln!("RECORD {} {} {}", r.record.ended, r.record.tracks.iter().filter(|t| t.dir == 2).map(|t| t.objects).sum::(), r.record.detail["counts"]); + } + server.shutdown().await; + } + _ => { + eprintln!("usage: ingest_energy relay | ingest_energy gateway "); + std::process::exit(2); + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/gateway.example.toml b/joule-code/joule-code-rs/crates/joule-moq-ingest/gateway.example.toml index 887e87187..e4ee55a36 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/gateway.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/gateway.example.toml @@ -58,3 +58,57 @@ retry_after_s = 2 # what a 409 asks a player to wait [summary] # out_path = "sessions.jsonl" # session records, one JSON line each; absent: kept in memory only +# link_out_path = "links.hex" # contribution link records (WCL1), one hexadecimal line each + +[rtmp] +# Enhanced RTMP ingest (live-ingest §12); off unless a listener is set. +listen = "127.0.0.1:1935" # RTMP over TCP +# tls_listen = "127.0.0.1:1936" # RTMPS, with the two files below +# cert_path = "rtmps-chain.pem" +# key_path = "rtmps-key.pem" # PKCS #8 +url = "" # what a stream key's response names; empty: rtmp:///live +stream_key_ttl_s = 60 # a stream key from POST /rtmp is good this long for its first use +max_sessions = 64 +max_message_bytes = 8388608 # the largest message the chunk stream takes +max_bitrate_kbps = 40000 # per publisher, every track, over limits.bitrate_window_ms +connect_timeout_ms = 15000 # a connection that has not published by then is closed +idle_timeout_ms = 20000 +chunk_size = 4096 # the chunk size the gateway sends with +reconnect_url = "" # the tcUrl a reconnect request names; empty: none +reconnect_grace_ms = 5000 # how long a session stays after a reconnect request + +[injector] +# SCTE 104 automation into signed cues (cue-injection); off unless listen is set. +# listen = "127.0.0.1:5167" +room = "" # the room id, 64 hexadecimal digits, one of [[rooms]] +# key_path = "injector.key" # 32 bytes, the injector principal's Ed25519 seed +# grant_path = "injector.wrg1" # the principal's room grant, permitting cue and meta +leap_seconds = 18 # GPS - UTC, for a UTC timestamp() +fps_num = 30000 # the frame rate duration_extension_frames counts in +fps_den = 1001 +dpi_pid_index = [] # the DPI_PID_index values answered; empty: any +max_pending = 256 # cues held for splices still to come + +# Contribution over SRT and RIST (WAI extensions/live-contribution.md). A publisher POSTs +# /srt or /rist with its room grant and proof, and gets a one-time session. +[contrib] +host = "" # the host name stated in a session's answer +session_ttl_s = 60 # a session not connected by then is dropped +max_sessions = 16 +max_bitrate_kbps = 80000 # per session, all streams, over limits.bitrate_window_ms +record_interval_ms = 10000 # how often a session signs a part of its link record +idle_ms = 5000 # a link that receives nothing this long is ended + +[contrib.srt] +# listen = "127.0.0.1:9000" # the SRT listener; absent: /srt is 404 +latency_ms = 120 # the connection's is the larger of the two ends' +pbkeylen = 16 # AES key bytes: 16, 24 or 32 +call_allow = [] # publishers' listeners the gateway may call: "ip:port" or "ip" + +[contrib.rist] +enabled = false # needs a build with the feature `rist` +bind_ip = "127.0.0.1" +port_min = 0 # the ports sessions are given; 0 and 0: any free port +port_max = 0 +buffer_ms = 1000 +aes = 128 # the Main Profile's PSK key bits: 128 or 256 diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/bin/link_vectors.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/bin/link_vectors.rs new file mode 100644 index 000000000..0901a6843 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/bin/link_vectors.rs @@ -0,0 +1,456 @@ +//! `joule-moq-ingest-link-vectors`: the conformance corpus of the contribution link record, +//! `WCL1` (WAI `extensions/live-contribution.md` §6), written from fixed seeds, so the +//! same bytes every run. +//! +//! ```text +//! joule-moq-ingest-link-vectors --out write the corpus +//! joule-moq-ingest-link-vectors --check regenerate and compare +//! ``` +//! +//! Each vector is a part (one `.wcl1` file) or a series (several, in order), with the +//! outcome §6.5 and §6.6 give it: accepted, or refused with its reason (and, in a series, +//! the index of the part refused). Each refused vector breaks one rule and keeps every +//! other: where a rule after the signature is broken, the record is signed again. + +use ed25519_dalek::SigningKey; +use joule_contrib::stats::{Part, Run, Summary}; +use joule_moq_ingest::linkrec::{self, ended, LinkRecord, Reason, TrackHead, MAX_LEN, MAX_RUNS}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::process::ExitCode; + +fn key() -> SigningKey { + SigningKey::from_bytes(&[0x5c; 32]) +} + +fn h32(tag: u8) -> [u8; 32] { + linkrec_sha(&[b"wai:links-vector".as_slice(), &[tag]]) +} + +fn linkrec_sha(parts: &[&[u8]]) -> [u8; 32] { + joule_moq_ingest::room::sha256(parts) +} + +fn tracks(groups: u64) -> Vec { + let mut t = vec![ + TrackHead { track: h32(1), receipt_alias: 0x0001_2345_6789_abcd, groups, head: if groups == 0 { [0; 32] } else { h32(100 + groups as u8) } }, + TrackHead { track: h32(2), receipt_alias: 0x000f_edcb_a987_6543, groups: groups * 5, head: if groups == 0 { [0; 32] } else { h32(150 + groups as u8) } }, + ]; + t.sort_by_key(|x| x.track); + t +} + +/// A part's account from its outcomes in order: `F` first try, `R` recovered, `D` dropped, +/// `O` outstanding. +fn account(seq_first: u64, outcomes: &str, start: u64, end: u64, last: bool) -> Part { + let mut a = joule_contrib::stats::Account::new(); + for (i, c) in outcomes.chars().enumerate() { + let s = seq_first + i as u64; + a.resolve( + s, + match c { + 'F' => joule_contrib::stats::Outcome::FirstTry { bytes: 1316 }, + 'R' => joule_contrib::stats::Outcome::Recovered { bytes: 1316, recovery_us: 20_000 + 1_000 * (i as u32 % 7), margin_us: 80_000 + 3_000 * (i as u32 % 5) }, + 'D' => joule_contrib::stats::Outcome::Dropped, + _ => joule_contrib::stats::Outcome::Outstanding, + }, + ); + } + let n = outcomes.matches(|c| c != 'F').count() as u64; + if n > 0 { + a.request(n + 1); + a.request(1); + a.rtt_sample(900); + a.rtt_sample(1_100); + a.rtt_sample(1_000); + for _ in 0..outcomes.matches('R').count() { + a.retransmitted(); + } + } + let mut p = a.cut(end, last); + p.start_us = start; + p.end_us = end; + p +} + +#[allow(clippy::too_many_arguments)] +fn record(protocol: u8, mode: u8, cipher: u16, part: u32, parent: [u8; 32], last: bool, why: u8, start: u64, end: u64, window: u64, account: Part, tracks: Vec) -> LinkRecord { + LinkRecord { + room: h32(10), + hop: key().verifying_key().to_bytes(), + principal: h32(11), + grant: h32(12), + session: h32(13)[..16].try_into().unwrap(), + part, + parent, + protocol, + mode, + cipher, + last, + ended: why, + start, + end, + window, + account, + tracks, + } +} + +const T0: u64 = 3_600_000_000; +const PART: u64 = 10_000_000; + +fn base() -> LinkRecord { + let outcomes = "FFFRRFFFFDFFFFRFFFFFFFFFFRRRFFFF"; + record(1, 1, 128, 0, [0; 32], false, ended::NOT_LAST, T0, T0 + PART, 120_000, account(1_000_000, outcomes, T0, T0 + PART, false), tracks(3)) +} + +struct Out { + files: BTreeMap>, + vectors: Vec, +} + +impl Out { + fn part(&mut self, name: &str, bytes: Vec, outcome: Result<(), Reason>, note: &str) { + let file = format!("{name}.wcl1"); + let mut v = json!({"name": name, "kind": "part", "files": [file], "note": note}); + match outcome { + Ok(()) => { + let r = LinkRecord::verify(&bytes).unwrap_or_else(|e| panic!("{name}: an accepted vector verifies: {e:?}")); + v["outcome"] = json!("accept"); + v["digest"] = json!(hex::encode(linkrec::digest(&bytes))); + v["summary"] = summary(&r); + } + Err(reason) => { + let got = LinkRecord::verify(&bytes).err(); + assert_eq!(got, Some(reason), "{name}: refused for the reason it is meant to show"); + v["outcome"] = json!("refuse"); + v["reason"] = json!(reason.code()); + } + } + self.files.insert(file, bytes); + self.vectors.push(v); + } + + fn series(&mut self, name: &str, parts: Vec>, outcome: Result<(), (usize, Reason)>, note: &str) { + let files: Vec = (0..parts.len()).map(|i| format!("{name}.{i}.wcl1")).collect(); + let mut v = json!({"name": name, "kind": "series", "files": files, "note": note}); + let got = linkrec::verify_series(&parts); + match outcome { + Ok(()) => { + let rs = got.unwrap_or_else(|e| panic!("{name}: an accepted series verifies: {e:?}")); + v["outcome"] = json!("accept"); + v["digests"] = json!(parts.iter().map(|b| hex::encode(linkrec::digest(b))).collect::>()); + v["summaries"] = json!(rs.iter().map(summary).collect::>()); + } + Err((at, reason)) => { + assert_eq!(got.err(), Some((at, reason)), "{name}: refused where and why it is meant to be"); + v["outcome"] = json!("refuse"); + v["reason"] = json!(reason.code()); + v["at"] = json!(at); + } + } + for (f, b) in files.iter().zip(parts) { + self.files.insert(f.clone(), b); + } + self.vectors.push(v); + } +} + +fn summary(r: &LinkRecord) -> Value { + let a = &r.account; + json!({ + "part": r.part, "protocol": r.protocol, "mode": r.mode, "cipher": r.cipher, "last": r.last, "ended": r.ended, + "seq_first": a.seq_first, "seq_count": a.seq_count, "delivered": a.delivered, "first_try": a.first_try, + "recovered": a.delivered - a.first_try, "dropped": a.dropped, "outstanding": a.outstanding, "runs": a.runs.len(), + "bytes": a.bytes, "size": r.body().len() + 64, + }) +} + +fn signed(r: &LinkRecord) -> Vec { + r.sign(&key()) +} + +fn corpus() -> Out { + let mut o = Out { files: BTreeMap::new(), vectors: Vec::new() }; + let k = key(); + + // ── Accepted parts ────────────────────────────────────────────────────────────── + let clean = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, &"F".repeat(40), T0, T0 + PART, false), tracks(2)); + o.part("srt_listener_clean", signed(&clean), Ok(()), "SRT, the gateway listening, AES-128: every packet at the first try"); + o.part("srt_recovered", signed(&base()), Ok(()), "SRT: losses found and recovered within the window, one dropped"); + let out = record(1, 1, 0, 0, [0; 32], true, ended::PEER_SHUTDOWN, T0, T0 + 4_000_000, 200_000, account(7, "FFFRFFOOFO", T0, T0 + 4_000_000, true), tracks(1)); + o.part("srt_last_outstanding", signed(&out), Ok(()), "a session of one part, its last: three sequence numbers outstanding when the caller shut down"); + let simple = record(2, 1, 0, 0, [0; 32], false, 0, T0, T0 + PART, 1_000_000, account(65_536, "FFFFRRRRFFFFFFFD", T0, T0 + PART, false), tracks(4)); + o.part("rist_simple", signed(&simple), Ok(()), "RIST Simple Profile, in the clear (the profile has no encryption)"); + let main = record(3, 1, 256, 0, [0; 32], false, 0, T0, T0 + PART, 1_000_000, account(70_000, "FFFRFFRFFF", T0, T0 + PART, false), tracks(2)); + o.part("rist_main_psk256", signed(&main), Ok(()), "RIST Main Profile under PSK with a 256-bit key"); + let caller = record(1, 2, 192, 0, [0; 32], false, 0, T0, T0 + PART, 300_000, account(9, "FFFFFFFFRF", T0, T0 + PART, false), tracks(1)); + o.part("srt_caller_aes192", signed(&caller), Ok(()), "SRT, the gateway calling the publisher's listener, AES-192"); + let none = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + 1_000_000, 120_000, account(5, "FFF", T0, T0 + 1_000_000, false), tracks(0)); + o.part("tracks_not_begun", signed(&none), Ok(()), "no group signed yet on either track: each head zero"); + let mut many = String::new(); + for _ in 0..(MAX_RUNS + 40) { + many.push_str("FR"); + } + let trunc = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 500_000, account(100, &many, T0, T0 + PART, false), tracks(2)); + assert!(trunc.account.runs_truncated); + o.part("runs_truncated", signed(&trunc), Ok(()), "more loss runs than a part lists: the first 512, marked truncated, the sums at most the part's"); + + // ── Refused parts ─────────────────────────────────────────────────────────────── + let good = signed(&base()); + let mut b = good.clone(); + b.resize(MAX_LEN + 1, 0); + o.part("too_large", b, Err(Reason::RecordTooLarge), "one byte past the largest record"); + o.part("truncated", good[..good.len() - 1].to_vec(), Err(Reason::Truncated), "the signature's last byte missing"); + let mut b = good.clone(); + b[0] = b'X'; + o.part("bad_magic", b, Err(Reason::Malformed), "not WCL1"); + let mut b = good.clone(); + b.push(0); + o.part("trailing_byte", b, Err(Reason::Malformed), "a byte after the signature"); + let mut b = good.clone(); + b[4] = 2; + o.part("unknown_version", b, Err(Reason::UnknownVersion), "version 2"); + let mut r = base(); + let mut small = [0u8; 32]; + small[0] = 1; + r.hop = small; + o.part("bad_key", r.sign(&k), Err(Reason::BadKey), "the hop's key is the identity point, of small order"); + let mut b = good.clone(); + let n = b.len(); + b[n - 10] ^= 0x40; + o.part("bad_signature", b, Err(Reason::BadSignature), "a bit of the signature flipped"); + let mut b = good.clone(); + b[300] ^= 1; + o.part("altered_count", b, Err(Reason::BadSignature), "a count changed after signing"); + + let link_bad = |o: &mut Out, name: &str, f: &dyn Fn(&mut LinkRecord), note: &str, reason: Reason| { + let mut r = base(); + f(&mut r); + o.part(name, r.sign(&key()), Err(reason), note); + }; + use Reason::*; + link_bad(&mut o, "protocol_unknown", &|r| r.protocol = 4, "protocol 4", LinkInvalid); + link_bad(&mut o, "mode_unknown", &|r| r.mode = 3, "mode 3", LinkInvalid); + link_bad(&mut o, "rist_simple_encrypted", &|r| r.protocol = 2, "the Simple Profile has no encryption, yet the cipher is 128", LinkInvalid); + link_bad(&mut o, "rist_main_aes192", &|r| { r.protocol = 3; r.cipher = 192 }, "PSK keys are 128 or 256 bits", LinkInvalid); + link_bad(&mut o, "srt_cipher_100", &|r| r.cipher = 100, "no such key length", LinkInvalid); + link_bad(&mut o, "last_without_reason", &|r| { r.last = true; r.account.last = true }, "the last part names no reason the session ended", LinkInvalid); + link_bad(&mut o, "reason_without_last", &|r| r.ended = ended::IDLE, "a reason on a part that is not the last", LinkInvalid); + link_bad(&mut o, "reason_unknown", &|r| { r.last = true; r.account.last = true; r.ended = 9 }, "reason 9", LinkInvalid); + link_bad(&mut o, "empty_span", &|r| { r.end = r.start; r.account.end_us = r.start }, "start equals end", LinkInvalid); + link_bad(&mut o, "no_window", &|r| r.window = 0, "a window of 0 µs", LinkInvalid); + link_bad(&mut o, "first_part_with_parent", &|r| r.parent = h32(77), "part 0 names a parent", LinkInvalid); + link_bad(&mut o, "later_part_without_parent", &|r| r.part = 3, "part 3 names none", LinkInvalid); + link_bad(&mut o, "tracks_unsorted", &|r| r.tracks.reverse(), "the tracks out of order", LinkInvalid); + link_bad(&mut o, "track_head_without_groups", &|r| r.tracks[0].groups = 0, "a head with no group signed", LinkInvalid); + link_bad(&mut o, "track_zero_digest", &|r| r.tracks[0].track = [0; 32], "a track digest of zero", LinkInvalid); + + link_bad(&mut o, "count_mismatch", &|r| r.account.seq_count += 1, "delivered + dropped + outstanding is one short of the count", AccountInvalid); + link_bad(&mut o, "first_try_over_delivered", &|r| { r.account.first_try = r.account.delivered + 1 }, "more at the first try than delivered", AccountInvalid); + link_bad(&mut o, "outstanding_not_last", &|r| { r.account.outstanding = 1; r.account.seq_count += 1 }, "outstanding in a part that is not the last", AccountInvalid); + link_bad(&mut o, "recovery_samples", &|r| r.account.recovery.samples += 1, "one recovery sample more than recovered", AccountInvalid); + link_bad(&mut o, "rtt_disordered", &|r| r.account.rtt = Summary { samples: 3, min: 2_000, median: 1_000, max: 3_000 }, "the RTT's minimum above its median", AccountInvalid); + link_bad(&mut o, "rtt_empty_with_figures", &|r| r.account.rtt = Summary { samples: 0, min: 0, median: 5, max: 5 }, "no RTT sample, yet figures", AccountInvalid); + link_bad(&mut o, "margin_past_window", &|r| r.account.margin_min = 120_001, "a recovered packet's margin larger than the window", AccountInvalid); + link_bad(&mut o, "requested_below_requests", &|r| r.account.requested = r.account.requests - 1, "requests naming fewer sequence numbers than there are requests", AccountInvalid); + + link_bad(&mut o, "runs_overlap", &|r| r.account.runs[1].first = r.account.runs[0].first + r.account.runs[0].count as u64, "two runs with no first-try packet between", RunsInvalid); + link_bad(&mut o, "run_outside", &|r| { let n = r.account.runs.len(); r.account.runs[n - 1].first = r.account.seq_first + r.account.seq_count }, "a run past the part's sequence numbers", RunsInvalid); + link_bad(&mut o, "run_empty", &|r| r.account.runs.push(Run { first: r.account.seq_first + r.account.seq_count - 1, count: 0, recovered: 0, dropped: 0, outstanding: 0 }), "a run of no sequence number", RunsInvalid); + link_bad(&mut o, "run_overfull", &|r| r.account.runs[0].dropped = r.account.runs[0].count, "recovered and dropped more than the run holds", RunsInvalid); + link_bad(&mut o, "runs_short", &|r| { r.account.runs.remove(0); }, "the runs do not sum to the part's losses", RunsInvalid); + link_bad(&mut o, "truncated_not_full", &|r| r.account.runs_truncated = true, "marked truncated with fewer runs than a part lists", RunsInvalid); + link_bad(&mut o, "run_overfull_alone", &|r| { r.account.runs[0].dropped = 1; r.account.runs[1].dropped = 0 }, "a run's recovered and dropped past its count, every sum still right", RunsInvalid); + link_bad(&mut o, "recovery_disordered", &|r| { let n = r.account.recovery.samples; r.account.recovery = Summary { samples: n, min: 30_000, median: 20_000, max: 40_000 } }, "the recoveries' minimum above their median", AccountInvalid); + link_bad(&mut o, "recovery_empty_with_figures", &|r| { r.account.first_try = r.account.delivered; r.account.recovery = Summary { samples: 0, min: 0, median: 0, max: 9 }; r.account.margin_min = 0; for x in r.account.runs.iter_mut() { x.recovered = 0; } r.account.runs.retain(|x| x.dropped > 0); r.account.seq_count = r.account.delivered + r.account.dropped; }, "no recovery sample, yet a maximum", AccountInvalid); + + // Counts past their bounds, flags out of their range, a signature's R of small order. + let mut r = base(); + for k in 0..9u8 { + r.tracks.push(TrackHead { track: h32(40 + k), receipt_alias: 9, groups: 1, head: h32(60 + k) }); + } + r.tracks.sort_by_key(|t| t.track); + r.tracks.dedup_by_key(|t| t.track); + assert!(r.tracks.len() > 8); + o.part("tracks_past_bound", r.sign(&k), Err(Malformed), "more track heads than a record holds"); + let mut many = String::new(); + for _ in 0..(MAX_RUNS + 1) { + many.push_str("FR"); + } + let mut r = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 500_000, account(100, &many, T0, T0 + PART, false), tracks(2)); + // The account lists 512 runs and truncates; write all 513 as if it did not. + let mut runs = Vec::new(); + for i in 0..(MAX_RUNS as u64 + 1) { + runs.push(Run { first: 101 + 2 * i, count: 1, recovered: 1, dropped: 0, outstanding: 0 }); + } + r.account.runs = runs; + r.account.runs_truncated = false; + o.part("runs_past_bound", r.sign(&k), Err(Malformed), "513 runs listed"); + let good_one = base(); + let b = good_one.sign(&k); + let at_last = 4 + 1 + 4 * 32 + 16 + 4 + 32 + 1 + 1 + 2; + let mut x = b.clone(); + x[at_last] = 2; + o.part("last_byte_two", x, Err(Malformed), "a last flag of 2"); + let at_trunc = 347 + 1 + 80 * good_one.tracks.len() + 2; + let mut x = b.clone(); + x[at_trunc] = 2; + o.part("truncated_byte_two", x, Err(Malformed), "a truncated flag of 2"); + let mut x = b.clone(); + let n = x.len(); + x[n - 64..n - 32].copy_from_slice(&{ + let mut id = [0u8; 32]; + id[0] = 1; + id + }); + o.part("small_order_r", x, Err(BadSignature), "the signature's R is the identity point, of small order"); + let margin = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, &"F".repeat(40), T0, T0 + PART, false), tracks(2)); + let mut m = margin.clone(); + m.account.margin_min = 5; + o.part("margin_without_recovery", m.sign(&k), Err(AccountInvalid), "a recovery margin with nothing recovered"); + let lost = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, "FFDD", T0, T0 + PART, false), tracks(2)); + o.part("two_dropped", lost.sign(&k), Ok(()), "two of four dropped, none recovered"); + let all_lost = record(1, 1, 128, 0, [0; 32], false, 0, T0, T0 + PART, 120_000, account(42, "DD", T0, T0 + PART, false), tracks(2)); + let mut m = all_lost.clone(); + m.account.bytes = 10; + o.part("bytes_without_delivery", m.sign(&k), Err(AccountInvalid), "bytes delivered with nothing delivered"); + + // ── Series ────────────────────────────────────────────────────────────────────── + let p0 = base(); + let b0 = signed(&p0); + let p1 = { + let a = account(p0.account.seq_first + p0.account.seq_count, "FFFFFFRFFFFFFFFF", T0 + PART, T0 + 2 * PART, false); + record(1, 1, 128, 1, linkrec::digest(&b0), false, 0, T0 + PART, T0 + 2 * PART, 120_000, a, tracks(5)) + }; + let b1 = signed(&p1); + let p2 = { + let a = account(p1.account.seq_first + p1.account.seq_count, "FFFFFFFFFO", T0 + 2 * PART, T0 + 2 * PART + 3_000_000, true); + record(1, 1, 128, 2, linkrec::digest(&b1), true, ended::DELETED, T0 + 2 * PART, T0 + 2 * PART + 3_000_000, 120_000, a, tracks(6)) + }; + let b2 = signed(&p2); + o.series("series_three_parts", vec![b0.clone(), b1.clone(), b2.clone()], Ok(()), "a session of three parts, each chained to the one before, the last ended by DELETE"); + o.series("series_starts_late", vec![b1.clone(), b2.clone()], Err((0, ChainBroken)), "a series whose first part is not part 0"); + let mut q = p1.clone(); + q.parent = h32(99); + o.series("series_wrong_parent", vec![b0.clone(), signed(&q), b2.clone()], Err((1, ChainBroken)), "part 1 names another parent"); + let mut q = p2.clone(); + q.part = 3; + o.series("series_skips_a_part", vec![b0.clone(), b1.clone(), signed(&q)], Err((2, ChainBroken)), "part 1 then part 3"); + let mut q = p1.clone(); + q.session = h32(55)[..16].try_into().unwrap(); + o.series("series_other_session", vec![b0.clone(), signed(&q)], Err((1, ChainBroken)), "part 1 of another session"); + let mut q = p1.clone(); + q.tracks[0].head = h32(200); + q.tracks[0].groups = p0.tracks[0].groups; + o.series("series_head_moved_without_groups", vec![b0.clone(), signed(&q)], Err((1, ChainBroken)), "a track's head changed with no group signed since"); + let mut q = p1.clone(); + q.tracks.remove(0); + o.series("series_track_dropped", vec![b0.clone(), signed(&q)], Err((1, ChainBroken)), "a track of part 0 missing from part 1"); + let mut last0 = p0.clone(); + last0.last = true; + last0.account.last = true; + last0.ended = ended::IDLE; + let bl = signed(&last0); + let mut q = p1.clone(); + q.parent = linkrec::digest(&bl); + o.series("series_part_after_last", vec![bl, signed(&q)], Err((1, ChainBroken)), "a part after the session's last"); + let mut q = p1.clone(); + q.account.seq_first += 1; + q.account.runs.iter_mut().for_each(|r| r.first += 1); + o.series("series_sequence_gap", vec![b0.clone(), signed(&q)], Err((1, SequenceGap)), "a sequence number between part 0 and part 1 accounted for by neither"); + let mut q = p1.clone(); + q.start += 1; + q.account.start_us += 1; + o.series("series_time_gap", vec![b0.clone(), signed(&q)], Err((1, SequenceGap)), "part 1 begins a microsecond after part 0 ended"); + let other = SigningKey::from_bytes(&[0x5d; 32]); + let mut q = p1.clone(); + q.hop = other.verifying_key().to_bytes(); + o.series("series_other_hop", vec![b0.clone(), q.sign(&other)], Err((1, ChainBroken)), "part 1 signed by another gateway"); + let same_rest = |f: &dyn Fn(&mut LinkRecord)| { + let mut q = p1.clone(); + f(&mut q); + signed(&q) + }; + o.series("series_other_room", vec![b0.clone(), same_rest(&|q| q.room = h32(56))], Err((1, ChainBroken)), "part 1 of another room"); + o.series("series_other_principal", vec![b0.clone(), same_rest(&|q| q.principal = h32(57))], Err((1, ChainBroken)), "part 1 of another owner"); + o.series("series_other_grant", vec![b0.clone(), same_rest(&|q| q.grant = h32(58))], Err((1, ChainBroken)), "part 1 under another grant"); + o.series("series_other_protocol", vec![b0.clone(), same_rest(&|q| { q.protocol = 3; q.cipher = 128 })], Err((1, ChainBroken)), "part 1 of another protocol"); + o.series("series_other_mode", vec![b0.clone(), same_rest(&|q| q.mode = 2)], Err((1, ChainBroken)), "part 1 says the gateway called"); + o.series("series_other_cipher", vec![b0.clone(), same_rest(&|q| q.cipher = 256)], Err((1, ChainBroken)), "part 1 under another key length"); + o.series("series_other_window", vec![b0.clone(), same_rest(&|q| q.window = 200_000)], Err((1, ChainBroken)), "part 1 with another window"); + o.series("series_track_alias_changed", vec![b0.clone(), same_rest(&|q| q.tracks[0].receipt_alias ^= 1)], Err((1, ChainBroken)), "a track's receipt chain changed"); + let mut q = p1.clone(); + q.tracks.push(TrackHead { track: h32(3), receipt_alias: 0x00ab_cdef, groups: 1, head: h32(201) }); + q.tracks.sort_by_key(|t| t.track); + let bq = signed(&q); + let mut q2 = p2.clone(); + q2.parent = linkrec::digest(&bq); + q2.tracks.push(TrackHead { track: h32(3), receipt_alias: 0x00ab_cdef, groups: 2, head: h32(202) }); + q2.tracks.sort_by_key(|t| t.track); + o.series("series_track_added", vec![b0.clone(), bq, signed(&q2)], Ok(()), "a track that begins in part 1, named again in part 2"); + o +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let (mode, dir) = match args.as_slice() { + [m, d] if m == "--out" || m == "--check" => (m.clone(), std::path::PathBuf::from(d)), + _ => { + eprintln!("usage: joule-moq-ingest-link-vectors --out|--check "); + return ExitCode::from(2); + } + }; + let o = corpus(); + let reasons: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect(); + for r in &reasons { + assert!(o.vectors.iter().any(|v| v["reason"] == *r), "every reason refuses a vector: {r}"); + } + let index = json!({ + "format": "wai-live-contribution-links/1", + "hop_seed": hex::encode([0x5c; 32]), + "hop": hex::encode(key().verifying_key().to_bytes()), + "reasons": reasons, + "vectors": o.vectors, + }); + let mut files = o.files; + files.insert("vectors.json".into(), (serde_json::to_string_pretty(&index).unwrap() + "\n").into_bytes()); + if mode == "--out" { + std::fs::create_dir_all(&dir).expect("the corpus directory"); + for (name, b) in &files { + std::fs::write(dir.join(name), b).expect("a corpus file writes"); + } + println!("wrote {} files to {}", files.len(), dir.display()); + ExitCode::SUCCESS + } else { + let mut bad = 0; + for (name, b) in &files { + match std::fs::read(dir.join(name)) { + Ok(have) if have == *b => {} + Ok(_) => { + eprintln!("differs: {name}"); + bad += 1; + } + Err(_) => { + eprintln!("missing: {name}"); + bad += 1; + } + } + } + if let Ok(rd) = std::fs::read_dir(&dir) { + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().into_owned(); + if (n.ends_with(".wcl1") || n == "vectors.json") && !files.contains_key(&n) { + eprintln!("not generated: {n}"); + bad += 1; + } + } + } + if bad == 0 { + println!("the corpus is what the generator writes: {} files", files.len()); + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/catalog.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/catalog.rs index 41789e6cc..485af8e65 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/catalog.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/catalog.rs @@ -66,6 +66,8 @@ pub fn receipt_alias(session: &[u8; 16], ns: &TrackNamespace, name: &str) -> u64 pub struct Entry { pub media: u8, pub ns: TrackNamespace, + /// The Track Name: the layer, `0` for a WHIP session's tracks (live-rooms §6.3). + pub name: String, pub codec: Codec, pub header: Option
, pub receipt_alias: u64, @@ -78,8 +80,10 @@ pub fn catalog(entries: &[Entry]) -> Vec { let mut tracks = Vec::new(); for e in entries { let namespace = render(&e.ns); + let receipts = format!("{}{RECEIPTS_SUFFIX}", e.name); let mut t = serde_json::Map::new(); - if e.codec == Codec::Opus { + let audio = !e.codec.is_video(); + if audio { let ch = e.header.as_ref().map_or(2, |h| h.channels.max(1)); t.insert("channelConfig".into(), json!(ch.to_string())); } @@ -90,28 +94,40 @@ pub fn catalog(entries: &[Entry]) -> Vec { t.insert("height".into(), json!(h.height)); } t.insert("isLive".into(), json!(true)); - t.insert("name".into(), json!(LAYER0)); + t.insert("name".into(), json!(e.name)); t.insert("namespace".into(), json!(namespace)); t.insert("packaging".into(), json!("wai")); t.insert("renderGroup".into(), json!(1)); t.insert("role".into(), json!(if e.codec.is_video() { "video" } else { "audio" })); - if e.codec == Codec::Opus { - t.insert("samplerate".into(), json!(48_000)); + if audio { + let rate = e.header.as_ref().map_or(0, |h| h.rate); + t.insert("samplerate".into(), json!(if rate > 0 { rate } else { 48_000 })); } t.insert("wai_capabilities".into(), json!([e.codec.capability()])); t.insert("wai_receipt_alias".into(), json!(e.receipt_alias)); t.insert("wai_receipts".into(), json!(true)); - t.insert("wai_receipts_track".into(), json!(format!("{LAYER0}{RECEIPTS_SUFFIX}"))); + t.insert("wai_receipts_track".into(), json!(receipts)); if let Some(h) = e.header.as_ref().filter(|h| h.width > 0) { t.insert("width".into(), json!(h.width)); } tracks.push(sorted(Value::Object(t))); - tracks.push(json!({"isLive": true, "name": format!("{LAYER0}{RECEIPTS_SUFFIX}"), "namespace": namespace, "packaging": "wai-receipts"})); + tracks.push(json!({"isLive": true, "name": receipts, "namespace": namespace, "packaging": "wai-receipts"})); } let v = json!({"tracks": tracks, "version": 1}); serde_json::to_vec(&sorted(v)).expect("a catalog serialises") } +/// [`catalog`], and a contribution session's link track (`extensions/live-contribution.md` +/// §6.4): `name` in the catalog's namespace `namespace`, packaging `wai-link`, its receipts +/// on `_receipts` under the chain `chain`. +pub fn catalog_with_link(entries: &[Entry], namespace: &str, name: &str, chain: u64) -> Vec { + let mut v: Value = serde_json::from_slice(&catalog(entries)).expect("a catalog reads"); + let tracks = v["tracks"].as_array_mut().expect("a catalog has tracks"); + tracks.push(json!({"isLive": true, "name": name, "namespace": namespace, "packaging": "wai-link", "wai_receipt_alias": chain, "wai_receipts": true, "wai_receipts_track": format!("{name}{RECEIPTS_SUFFIX}")})); + tracks.push(json!({"isLive": true, "name": format!("{name}{RECEIPTS_SUFFIX}"), "namespace": namespace, "packaging": "wai-receipts"})); + serde_json::to_vec(&sorted(v)).expect("a catalog serialises") +} + /// A value with every object's members in ascending order of name, whatever order the /// map type keeps. fn sorted(v: Value) -> Value { @@ -173,8 +189,8 @@ mod tests { let v = media_namespace(&room, &owner, crate::room::media::VIDEO); let a = media_namespace(&room, &owner, crate::room::media::AUDIO); let entries = vec![ - Entry { media: 2, ns: v.clone(), codec: Codec::Avc, header: Some(Header { width: 0, height: 0, codec: Some("avc1.42E01F".into()), channels: 0 }), receipt_alias: 11 }, - Entry { media: 1, ns: a.clone(), codec: Codec::Opus, header: Some(Header { width: 0, height: 0, codec: Some("opus".into()), channels: 2 }), receipt_alias: 12 }, + Entry { media: 2, ns: v.clone(), name: LAYER0.into(), codec: Codec::Avc, header: Some(Header { width: 0, height: 0, codec: Some("avc1.42E01F".into()), channels: 0, rate: 0 }), receipt_alias: 11 }, + Entry { media: 1, ns: a.clone(), name: LAYER0.into(), codec: Codec::Opus, header: Some(Header { width: 0, height: 0, codec: Some("opus".into()), channels: 2, rate: 48_000 }), receipt_alias: 12 }, ]; let b = catalog(&entries); let s = String::from_utf8(b.clone()).unwrap(); diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/config.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/config.rs index 0f0cd0580..26a0aaf62 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/config.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/config.rs @@ -30,6 +30,188 @@ pub struct GatewayConfig { pub whep: WhepConfig, #[serde(default)] pub summary: SummaryConfig, + #[serde(default)] + pub rtmp: RtmpConfig, + #[serde(default)] + pub injector: InjectorConfig, + #[serde(default)] + pub contrib: ContribConfig, +} + +/// The Enhanced RTMP ingest (`extensions/live-ingest.md` §12). Off unless `listen` or +/// `tls_listen` is set. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RtmpConfig { + /// Where RTMP over TCP listens. + #[serde(default)] + pub listen: Option, + /// Where RTMP over TLS listens (RTMPS), with `cert_path` and `key_path`. + #[serde(default)] + pub tls_listen: Option, + /// The certificate chain, PEM, and its PKCS #8 private key, PEM. + #[serde(default)] + pub cert_path: Option, + #[serde(default)] + pub key_path: Option, + /// The URL a stream key's response names, for an encoder's server field, e.g. + /// `rtmps://ingest.example/live`. Empty: `rtmp:///live`. + #[serde(default)] + pub url: String, + /// How long a stream key the `/rtmp` resource issues stays good for its first use. + #[serde(default = "d_key_ttl")] + pub stream_key_ttl_s: u64, + /// The most RTMP connections at once. + #[serde(default = "d_rtmp_sessions")] + pub max_sessions: usize, + /// The largest message the chunk stream takes, in bytes: a frame larger is refused, + /// and ends the connection. + #[serde(default = "d_rtmp_message")] + pub max_message_bytes: usize, + /// What a publisher may send, over every track, in kilobits per second, over the + /// limits' `bitrate_window_ms`; past it the session ends (`bitrate_exceeded`). + #[serde(default = "d_rtmp_kbps")] + pub max_bitrate_kbps: u32, + /// A connection that has not published by then is closed. + #[serde(default = "d_rtmp_connect")] + pub connect_timeout_ms: u64, + /// A publishing session that has carried no media for this long ends (`idle`). + #[serde(default = "d_rtmp_idle")] + pub idle_timeout_ms: u64, + /// The chunk size the gateway sends with. + #[serde(default = "d_rtmp_chunk")] + pub chunk_size: u32, + /// The `tcUrl` a reconnect request names (E-RTMP *Reconnect Request*); empty: none, so + /// the publisher reconnects to the URL it used. + #[serde(default)] + pub reconnect_url: String, + /// How long the gateway keeps a session after it sends a reconnect request, for the + /// publisher to reach its next keyframe and leave. + #[serde(default = "d_rtmp_grace")] + pub reconnect_grace_ms: u64, + /// TLS identity given in code (not read from TOML): the chain, DER, and the PKCS #8 + /// key, DER. + #[serde(skip)] + pub tls_der: Option<(Vec>, Vec)>, +} + +fn d_key_ttl() -> u64 { + 60 +} +fn d_rtmp_sessions() -> usize { + 64 +} +fn d_rtmp_message() -> usize { + 8 << 20 +} +fn d_rtmp_kbps() -> u32 { + 40_000 +} +fn d_rtmp_connect() -> u64 { + 15_000 +} +fn d_rtmp_idle() -> u64 { + 20_000 +} +fn d_rtmp_chunk() -> u32 { + 4096 +} +fn d_rtmp_grace() -> u64 { + 5_000 +} + +impl Default for RtmpConfig { + fn default() -> Self { + RtmpConfig { + listen: None, + tls_listen: None, + cert_path: None, + key_path: None, + url: String::new(), + stream_key_ttl_s: d_key_ttl(), + max_sessions: d_rtmp_sessions(), + max_message_bytes: d_rtmp_message(), + max_bitrate_kbps: d_rtmp_kbps(), + connect_timeout_ms: d_rtmp_connect(), + idle_timeout_ms: d_rtmp_idle(), + chunk_size: d_rtmp_chunk(), + reconnect_url: String::new(), + reconnect_grace_ms: d_rtmp_grace(), + tls_der: None, + } + } +} + +/// The SCTE 104 injector (WAI `extensions/cue-injection.md`). Off unless `listen` is set. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InjectorConfig { + /// Where the automation system's API connection is accepted (SCTE 104's port is 5167). + #[serde(default)] + pub listen: Option, + /// The room the injector's cues are for: its id, 64 hexadecimal digits, a room the + /// gateway holds. + #[serde(default)] + pub room: String, + /// A file of 32 bytes, the Ed25519 seed of the injector's principal, which signs its + /// events. + #[serde(default)] + pub key_path: Option, + /// The principal's room grant (`WRG1`), which permits `cue` and `meta`. + #[serde(default)] + pub grant_path: Option, + /// The GPS − UTC leap seconds a UTC `timestamp()` is read with (SCTE 104 §12.4: its + /// seconds count from 1980-01-06 with the leap seconds since). + #[serde(default = "d_leap")] + pub leap_seconds: u8, + /// The video frame rate a segmentation descriptor's `duration_extension_frames` is + /// counted in, as a fraction. + #[serde(default = "d_fps_num")] + pub fps_num: u32, + #[serde(default = "d_fps_den")] + pub fps_den: u32, + /// The DPI_PID_index values the injector answers; empty: any. + #[serde(default)] + pub dpi_pid_index: Vec, + /// The most cues held for a splice that has not come yet. + #[serde(default = "d_pending")] + pub max_pending: usize, + /// A seed and a grant given in code (not read from TOML). + #[serde(skip)] + pub key_seed: Option<[u8; 32]>, + #[serde(skip)] + pub grant: Option>, +} + +fn d_leap() -> u8 { + 18 +} +fn d_fps_num() -> u32 { + 30_000 +} +fn d_fps_den() -> u32 { + 1_001 +} +fn d_pending() -> usize { + 256 +} + +impl Default for InjectorConfig { + fn default() -> Self { + InjectorConfig { + listen: None, + room: String::new(), + key_path: None, + grant_path: None, + leap_seconds: d_leap(), + fps_num: d_fps_num(), + fps_den: d_fps_den(), + dpi_pid_index: Vec::new(), + max_pending: d_pending(), + key_seed: None, + grant: None, + } + } } #[derive(Clone, Debug, Deserialize)] @@ -362,6 +544,128 @@ pub struct SummaryConfig { /// Each session's record is appended here as one JSON line, when set. #[serde(default)] pub out_path: Option, + /// Each contribution link record part (`WCL1`) is appended here, as one line of + /// hexadecimal, when set. + #[serde(default)] + pub link_out_path: Option, +} + +/// Contribution over SRT and RIST (`extensions/live-contribution.md`). +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContribConfig { + /// The host name callers connect to, stated in a session's answer; empty: none stated. + #[serde(default)] + pub host: String, + /// How long a session a POST opened waits for its connection before it is dropped. + #[serde(default = "d_ttl")] + pub session_ttl_s: u64, + /// SRT and RIST sessions at once. + #[serde(default = "d_contrib_sessions")] + pub max_sessions: usize, + /// The most a session may carry, all streams, over `limits.bitrate_window_ms`; past + /// it the session is ended (`bitrate_exceeded`). + #[serde(default = "d_contrib_kbps")] + pub max_bitrate_kbps: u32, + /// How often a session signs a part of its link record. + #[serde(default = "d_record_interval")] + pub record_interval_ms: u64, + /// A link that receives nothing for this long is ended. + #[serde(default = "d_contrib_idle")] + pub idle_ms: u64, + #[serde(default)] + pub srt: SrtConfig, + #[serde(default)] + pub rist: RistConfig, +} + +fn d_ttl() -> u64 { + 60 +} +fn d_contrib_sessions() -> usize { + 16 +} +fn d_contrib_kbps() -> u32 { + 80_000 +} +fn d_record_interval() -> u64 { + 10_000 +} +fn d_contrib_idle() -> u64 { + 5_000 +} + +impl Default for ContribConfig { + fn default() -> Self { + ContribConfig { host: String::new(), session_ttl_s: d_ttl(), max_sessions: d_contrib_sessions(), max_bitrate_kbps: d_contrib_kbps(), record_interval_ms: d_record_interval(), idle_ms: d_contrib_idle(), srt: SrtConfig::default(), rist: RistConfig::default() } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SrtConfig { + /// The SRT listener's UDP address; absent: no listener (and `/srt` is `404`). + #[serde(default)] + pub listen: Option, + /// The listener's latency, ms: the connection's is the larger of the two ends'. + #[serde(default = "d_latency")] + pub latency_ms: u16, + /// The key length, bytes (16, 24 or 32), a session's answer states. + #[serde(default = "d_pbkeylen")] + pub pbkeylen: usize, + /// The publishers' listeners the gateway may call (`call` in a POST's body): each + /// `ip:port`, or `ip` for any port of it. Empty: the gateway calls none. + #[serde(default)] + pub call_allow: Vec, +} + +fn d_latency() -> u16 { + 120 +} +fn d_pbkeylen() -> usize { + 16 +} + +impl Default for SrtConfig { + fn default() -> Self { + SrtConfig { listen: None, latency_ms: d_latency(), pbkeylen: d_pbkeylen(), call_allow: Vec::new() } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RistConfig { + /// Whether `/rist` opens sessions (and the build carries RIST: feature `rist`). + #[serde(default)] + pub enabled: bool, + /// The address each session's port is bound on. + #[serde(default = "d_bind_ip")] + pub bind_ip: IpAddr, + /// The ports sessions are given, `port_min` to `port_max` (the Simple Profile takes an + /// even port and the one above). 0 and 0: any free port. + #[serde(default)] + pub port_min: u16, + #[serde(default)] + pub port_max: u16, + /// The receiver's buffer, ms (VSF TR-06-1 Appendix B suggests 1 000). + #[serde(default = "d_rist_buffer")] + pub buffer_ms: u64, + /// The Main Profile's PSK key length, bits: 128 or 256. + #[serde(default = "d_rist_aes")] + pub aes: u16, +} + +fn d_rist_buffer() -> u64 { + 1_000 +} +fn d_rist_aes() -> u16 { + 128 +} + +impl Default for RistConfig { + fn default() -> Self { + RistConfig { enabled: false, bind_ip: d_bind_ip(), port_min: 0, port_max: 0, buffer_ms: d_rist_buffer(), aes: d_rist_aes() } + } } impl GatewayConfig { @@ -382,6 +686,9 @@ impl GatewayConfig { timeline: Timeline::default(), whep: WhepConfig::default(), summary: SummaryConfig::default(), + rtmp: RtmpConfig::default(), + injector: InjectorConfig::default(), + contrib: ContribConfig::default(), } } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/contrib.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/contrib.rs new file mode 100644 index 000000000..7a9da3962 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/contrib.rs @@ -0,0 +1,656 @@ +//! Contribution over UDP into a room's tracks: SRT and RIST sessions (WAI +//! `extensions/live-ingest.md` §13 and `extensions/live-contribution.md`). +//! +//! - **Admission.** A publisher first POSTs `/srt` or `/rist` with its room grant and the +//! proof of its key, admitted as WHIP is (live-ingest §3). The gateway answers with a +//! one-time session: for SRT the stream ID `#!::r=/,m=publish,s=` +//! and the passphrase the handshake must carry (the gateway's listener checks both); for +//! RIST a port of its own, the CNAME the flow's RTCP must give, and, in the Main Profile, +//! the PSK passphrase. With `call` in its body, a POST to `/srt` has the gateway call the +//! publisher's SRT listener instead, where the configuration lets it. +//! - **The stream.** The link delivers the transport stream at its delivery time (SRT's TSBPD +//! over its latency, RIST's buffer); the gateway demultiplexes it, takes the first video +//! stream (H.264, HEVC) and the first AAC stream, and gives each access unit (each ADTS +//! frame) to the track's packager with its PTS as the media time on the room clock +//! (live-ingest §7) and its arrival from the link as its arrival: the same objects, +//! groups, catalog and receipts as a WHIP session's (live-ingest §4 to §8). +//! - **The link record.** Every `record_interval_ms`, and when the session ends, the gateway +//! signs a part of the link's `WCL1` record ([`crate::linkrec`]) and publishes it on the +//! owner's `link` track with its receipts: every sequence number delivered, recovered, +//! dropped or outstanding, the requests and the round trips, and the head of every media +//! track's receipt chain at the part's end. + +use crate::catalog::{self, Entry}; +use crate::frame::{Codec, GrantRef}; +use crate::gateway::{random_hex, Cmd, Gateway, Handle}; +use crate::linkrec::{self, LinkRecord, TrackHead}; +use crate::package::{sign_group, Emit, Packager, TrackOut}; +use crate::record::{Energy, LedgerEntry, SessionRecord, SignedRecord, TrackEntry, FORMAT}; +use crate::relay::{Published, Publisher}; +use crate::room::{media, Admitted, Room, NO_CEILING}; +use joule_contrib::link::{Link, Protocol}; +use joule_contrib::srt::packet::rej; +use joule_contrib::ts::{self, Demux}; +use joule_moq::TrackName; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +const CATALOG_ALIAS: u64 = 1; +const CATALOG_RECEIPTS_ALIAS: u64 = 6; +const LINK_ALIAS: u64 = 7; +const LINK_RECEIPTS_ALIAS: u64 = 8; +pub const LINK_NAME: &str = "link"; +const CATALOG_HOLD: Duration = Duration::from_millis(1_000); +const END_GRACE: Duration = Duration::from_millis(250); + +fn aliases(m: u8) -> (u64, u64) { + if m == media::AUDIO { (2, 3) } else { (4, 5) } +} + +fn priority(m: u8) -> u8 { + if m == media::AUDIO { 0 } else { 1 } +} + +/// A session a POST opened, not yet connected. +pub struct Pending { + pub id: String, + pub session: [u8; 16], + pub room: Arc, + pub ad: Admitted, + pub token_hash: [u8; 32], + pub passphrase: Option, + pub protocol: Protocol, + pub created: Instant, +} + +impl Pending { + pub fn tag(&self) -> u64 { + u64::from_le_bytes(self.session[..8].try_into().unwrap()) + } +} + +/// The gateway's contribution sessions. +#[derive(Default)] +pub struct Contrib { + /// Opened, not yet connected, by tag. + pub pending: Mutex>, + /// Admitted by the SRT listener, the handshake under way, by tag. + pub admitted: Mutex>, + pub sessions: Mutex>, + pub active: AtomicUsize, + /// (room, owner) of every running session. + pub owners: Mutex>, + /// Every link record part signed so far, as bytes. + pub records: Mutex>>, + pub refused: AtomicU64, +} + +impl Contrib { + pub fn tag_of(id: &str) -> Option { + let b = hex::decode(id).ok()?; + (b.len() == 16).then(|| u64::from_le_bytes(b[..8].try_into().unwrap())) + } + + /// Drop pending sessions older than `ttl`. + pub fn prune(&self, ttl: Duration) { + self.pending.lock().unwrap().retain(|_, p| p.created.elapsed() <= ttl); + self.admitted.lock().unwrap().retain(|_, p| p.created.elapsed() <= ttl * 2); + } +} + +/// A fresh one-time session for `ad`. +pub fn open(room: Arc, ad: Admitted, token_hash: [u8; 32], protocol: Protocol, passphrase: bool) -> Pending { + let id = random_hex(16); + let session: [u8; 16] = hex::decode(&id).unwrap().try_into().unwrap(); + // 24 random bytes in base64url: 32 characters, within SRT's 10 to 79. + let passphrase = passphrase.then(|| { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(joule_contrib::random_bytes(24)) + }); + Pending { id, session, room, ad, token_hash, passphrase, protocol, created: Instant::now() } +} + +/// The stream ID a caller presents for `p` (live-ingest §13). +pub fn stream_id(p: &Pending) -> String { + format!("#!::r={}/{},m=publish,s={}", hex::encode(p.room.id), hex::encode(p.ad.grant.principal), p.id) +} + +/// The keys of a stream ID in the `#!::` key-value syntax. +fn sid_keys(s: &str) -> Option> { + let body = s.strip_prefix("#!::")?; + let mut m = HashMap::new(); + for kv in body.split(',') { + let (k, v) = kv.split_once('=')?; + m.insert(k.trim().to_string(), v.trim().to_string()); + } + Some(m) +} + +/// The SRT listener's decision on a caller (live-ingest §13, its rejection codes). +pub fn admit_srt(gw: &Gateway, stream_id: Option<&str>) -> Result { + let refuse = |c: u32| { + gw.contrib.refused.fetch_add(1, Ordering::Relaxed); + Err(c) + }; + let Some(keys) = stream_id.and_then(sid_keys) else { return refuse(rej::UNAUTHORIZED) }; + match keys.get("m").map(String::as_str) { + Some("publish") => {} + // `m=request` is the mapping's play mode, which this gateway does not serve. + _ => return refuse(rej::BAD_MODE), + } + let Some(tag) = keys.get("s").and_then(|s| Contrib::tag_of(s)) else { return refuse(rej::UNAUTHORIZED) }; + gw.contrib.prune(Duration::from_secs(gw.cfg.contrib.session_ttl_s)); + let mut pending = gw.contrib.pending.lock().unwrap(); + let Some(p) = pending.get(&tag).filter(|p| p.protocol == Protocol::Srt && keys.get("s") == Some(&p.id)) else { return refuse(rej::UNAUTHORIZED) }; + let (room_hex, owner_hex) = match keys.get("r").and_then(|r| r.split_once('/')) { + Some((a, b)) => (a.to_string(), b.to_string()), + None => return refuse(rej::NOTFOUND), + }; + if !gw.rooms.contains_key(&<[u8; 32]>::try_from(hex::decode(&room_hex).unwrap_or_default().as_slice()).unwrap_or([0; 32])) { + return refuse(rej::NOTFOUND); + } + if room_hex != hex::encode(p.room.id) || owner_hex != hex::encode(p.ad.grant.principal) { + return refuse(rej::FORBIDDEN); + } + if !p.ad.grant.good_at(gw.clock.unix_us()) { + return refuse(rej::UNAUTHORIZED); + } + if gw.contrib.active.load(Ordering::SeqCst) >= gw.cfg.contrib.max_sessions { + return refuse(rej::OVERLOAD); + } + if gw.contrib.owners.lock().unwrap().contains(&(p.room.id, p.ad.grant.principal)) { + return refuse(rej::CONFLICT); + } + // The session ID is spent by its first use. + let p = pending.remove(&tag).unwrap(); + gw.contrib.admitted.lock().unwrap().insert(tag, p); + Ok(tag) +} + +/// Run a session over `link` for `p` until it ends. +pub async fn run(gw: Arc, p: Pending, link: Link) { + let key = (p.room.id, p.ad.grant.principal); + if !gw.contrib.owners.lock().unwrap().insert(key) { + link.close(); + return; + } + gw.contrib.active.fetch_add(1, Ordering::SeqCst); + let id = p.id.clone(); + let r = Session::start(gw.clone(), p, link).await; + match r { + Ok(s) => s.run().await, + Err(e) => { + let (why, link) = *e; + tracing::warn!(session = %id, "a contribution session could not start: {why}"); + link.close(); + } + } + gw.contrib.sessions.lock().unwrap().remove(&id); + gw.contrib.owners.lock().unwrap().remove(&key); + gw.contrib.active.fetch_sub(1, Ordering::SeqCst); +} + +struct LinkTrack { + chain: u64, + group: Option, + prev: Option<[u8; 32]>, + part: u32, + parent: [u8; 32], + part_start: u64, +} + +struct Session { + gw: Arc, + p: Pending, + owner: String, + link: Link, + publisher: Publisher, + packagers: BTreeMap, + demux: Demux, + /// PID → (media kind, codec) of the streams carried. + streams: BTreeMap, + unsupported_streams: HashSet, + catalog_group: Option, + catalog_chain: u64, + catalog_link: Option<[u8; 32]>, + catalog_dirty: bool, + first_frame: Option, + lt: LinkTrack, + start_us: u64, + window_start: Instant, + window_bytes: u64, + bytes_in: u64, + frames_in: Arc, + last_pts: HashMap, + cmd: mpsc::Receiver, + delete_reply: Option>, +} + +impl Session { + /// The session's tracks published; or why not, with the link handed back. + async fn start(gw: Arc, p: Pending, link: Link) -> Result> { + let owner = hex::encode(p.ad.grant.principal); + let kinds: Vec = [media::VIDEO, media::AUDIO].into_iter().filter(|k| p.ad.grant.caps.publishes(*k)).collect(); + let (client, endpoint) = match gw.link.connect().await { + Ok(x) => x, + Err(e) => return Err(Box::new((format!("relay_unreachable: {e}"), link))), + }; + let cat_ns = catalog::catalog_namespace(&p.room.id, &owner); + let mut published = vec![ + Published { ns: cat_ns.clone(), name: TrackName::from_str(catalog::CATALOG_NAME), alias: CATALOG_ALIAS, priority: 0 }, + Published { ns: cat_ns.clone(), name: catalog::receipts_name(catalog::CATALOG_NAME), alias: CATALOG_RECEIPTS_ALIAS, priority: 0 }, + Published { ns: cat_ns.clone(), name: TrackName::from_str(LINK_NAME), alias: LINK_ALIAS, priority: 0 }, + Published { ns: cat_ns.clone(), name: catalog::receipts_name(LINK_NAME), alias: LINK_RECEIPTS_ALIAS, priority: 0 }, + ]; + let grant_ref = GrantRef { grant_hash: p.ad.digest, joule_ceiling_micro: p.ad.grant.joules_ceiling, funds_ceiling: (p.ad.grant.funds_ceiling != NO_CEILING).then_some(p.ad.grant.funds_ceiling) }; + let t0 = p.room.descriptor.t0_us; + let mut packagers = BTreeMap::new(); + for &k in &kinds { + let ns = catalog::media_namespace(&p.room.id, &owner, k); + let (ma, ra) = aliases(k); + let out = TrackOut { media: k, media_alias: ma, receipts_alias: ra, receipt_alias: catalog::receipt_alias(&p.session, &ns, catalog::LAYER0), priority: priority(k) }; + published.push(Published { ns: ns.clone(), name: TrackName::from_str(catalog::LAYER0), alias: ma, priority: priority(k) }); + published.push(Published { ns: ns.clone(), name: catalog::receipts_name(catalog::LAYER0), alias: ra, priority: priority(k) }); + let g = &gw.cfg.groups; + // A transport stream's PTS is a 90 kHz clock for every stream. + packagers.insert(k, Packager::new(out, 90_000, gw.cfg.timeline.max_skew_ms * 1000, grant_ref.clone(), gw.signer.clone(), t0, g.audio_group_ms * 1000, g.max_video_group_ms * 1000, gw.cfg.limits.max_frame_bytes)); + } + let publisher = match Publisher::start(client, endpoint, published, gw.cfg.limits.max_queue_bytes).await { + Ok(p) => p, + Err(e) => return Err(Box::new((format!("relay_refused: {e}"), link))), + }; + let (tx, cmd) = mpsc::channel(8); + let frames_in = Arc::new(AtomicU64::new(0)); + gw.contrib.sessions.lock().unwrap().insert(p.id.clone(), Handle { tx, token_hash: p.token_hash, frames: frames_in.clone() }); + let now = gw.clock.unix_us().saturating_sub(t0); + let catalog_chain = catalog::receipt_alias(&p.session, &cat_ns, catalog::CATALOG_NAME); + let link_chain = catalog::receipt_alias(&p.session, &cat_ns, LINK_NAME); + Ok(Session { + owner, + publisher, + packagers, + demux: Demux::new(), + streams: BTreeMap::new(), + unsupported_streams: HashSet::new(), + catalog_group: None, + catalog_chain, + catalog_link: None, + catalog_dirty: false, + first_frame: None, + lt: LinkTrack { chain: link_chain, group: None, prev: None, part: 0, parent: [0; 32], part_start: now }, + start_us: now, + window_start: Instant::now(), + window_bytes: 0, + bytes_in: 0, + frames_in, + last_pts: HashMap::new(), + cmd, + delete_reply: None, + link, + p, + gw, + }) + } + + fn room_now(&self) -> u64 { + self.gw.clock.unix_us().saturating_sub(self.p.room.descriptor.t0_us) + } + + async fn run(mut self) { + let mut tick = tokio::time::interval(Duration::from_millis(100)); + let mut last_part = Instant::now(); + let interval = Duration::from_millis(self.gw.cfg.contrib.record_interval_ms.max(100)); + let mut ending: Option = None; + let why = loop { + tokio::select! { + d = self.link.data.recv() => match d { + Some(payload) => { + if let Some(w) = self.on_payload(&payload) { + ending.get_or_insert(w); + self.link.close(); + } + } + None => break ending.clone().or_else(|| self.link.closed_reason()).unwrap_or_else(|| "closed".into()), + }, + c = self.cmd.recv() => match c { + Some(Cmd::Delete { reply }) => { + self.delete_reply = Some(reply); + ending.get_or_insert_with(|| "deleted".into()); + self.link.close(); + } + Some(Cmd::Patch { reply, .. }) => { + let _ = reply.send(crate::gateway::problem(hyper::StatusCode::METHOD_NOT_ALLOWED, "not_found", "a contribution session has no ICE to patch")); + } + None => { + ending.get_or_insert_with(|| "gateway_stopped".into()); + self.link.close(); + } + }, + _ = tick.tick() => { + if self.publisher.failed.load(Ordering::Relaxed) { + ending.get_or_insert_with(|| "relay_lost".into()); + self.link.close(); + } + if self.window_start.elapsed() >= Duration::from_millis(self.gw.cfg.limits.bitrate_window_ms) { + self.window_start = Instant::now(); + self.window_bytes = 0; + } + self.publish_catalog(false); + if last_part.elapsed() >= interval && !self.link.account.lock().unwrap().open_is_empty() { + last_part = Instant::now(); + self.part(false, ""); + } + } + } + }; + self.end(&why).await; + } + + /// One delivered payload of the link: seven TS packets, as a rule. + fn on_payload(&mut self, payload: &[u8]) -> Option { + self.bytes_in += payload.len() as u64; + self.window_bytes += payload.len() as u64; + let window_ms = self.gw.cfg.limits.bitrate_window_ms.max(1); + if self.window_bytes * 8 > self.max_kbps() * window_ms { + return Some("bitrate_exceeded".into()); + } + for pes in self.demux.push(payload) { + if let Some(why) = self.on_pes(pes) { + return Some(why); + } + } + None + } + + fn max_kbps(&self) -> u64 { + rate_bound_kbps(self.link.info.protocol, self.gw.cfg.contrib.max_bitrate_kbps as u64, self.link.info.latency_us) + } + + /// The stream a PES belongs to: the first video stream and the first AAC stream the PMT + /// declares are carried, each to its track. + fn stream_of(&mut self, pid: u16, st: u8) -> Option<(u8, Codec)> { + if let Some(s) = self.streams.get(&pid) { + return Some(*s); + } + let (kind, codec) = match st { + ts::ST_H264 => (media::VIDEO, Codec::Avc), + ts::ST_HEVC => (media::VIDEO, Codec::Hevc), + ts::ST_AAC_ADTS => (media::AUDIO, Codec::Aac), + _ => { + self.unsupported_streams.insert(pid); + return None; + } + }; + if self.streams.values().any(|(k, _)| *k == kind) || !self.packagers.contains_key(&kind) { + self.unsupported_streams.insert(pid); + return None; + } + self.streams.insert(pid, (kind, codec)); + Some((kind, codec)) + } + + fn on_pes(&mut self, pes: ts::Pes) -> Option { + let (kind, codec) = self.stream_of(pes.pid, pes.stream_type)?; + let arrival = self.room_now(); + // A PES with no PTS continues from the one before. + let pts = match pes.pts { + Some(v) => v, + None => self.last_pts.get(&pes.pid).map_or(0, |v| v + 3000), + }; + self.last_pts.insert(pes.pid, pts); + self.first_frame.get_or_insert_with(Instant::now); + let mut emits = Vec::new(); + let p = self.packagers.get_mut(&kind)?; + let mut catalog = false; + if codec == Codec::Aac { + // One ADTS frame an object, each at its own time: 1024 samples a frame. + let rate = ts::adts_frames(&pes.data).first().and_then(|f| ts::adts_config(f)).map_or(48_000, |c| c.1.max(1)) as u64; + for (k, f) in ts::adts_frames(&pes.data).iter().enumerate() { + self.frames_in.fetch_add(1, Ordering::Relaxed); + let t = pts + k as u64 * 1024 * 90_000 / rate; + catalog |= p.on_frame(codec, f, t, arrival, false, &mut emits).1; + } + } else { + self.frames_in.fetch_add(1, Ordering::Relaxed); + catalog |= p.on_frame(codec, &pes.data, pts, arrival, pes.random_access, &mut emits).1; + } + self.catalog_dirty |= catalog; + for e in emits { + if !self.publisher.send(e) { + return Some("relay_backpressure".into()); + } + } + None + } + + fn publish_catalog(&mut self, force: bool) { + if !self.catalog_dirty { + return; + } + let all = self.packagers.values().all(|p| p.codec.is_some()); + let held = self.first_frame.is_some_and(|t| t.elapsed() >= CATALOG_HOLD); + if !all && !held && !force { + return; + } + let entries: Vec = self + .packagers + .values() + .filter_map(|p| Some(Entry { media: p.out.media, ns: catalog::media_namespace(&self.p.room.id, &self.owner, p.out.media), name: catalog::LAYER0.into(), codec: p.codec?, header: p.header.clone(), receipt_alias: p.out.receipt_alias })) + .collect(); + if entries.is_empty() && !force { + return; + } + let g = match self.catalog_group { + Some(g) => g + 1, + None => self.room_now(), + }; + self.catalog_group = Some(g); + self.catalog_dirty = false; + let ns = catalog::catalog_namespace(&self.p.room.id, &self.owner); + let bytes = catalog::catalog_with_link(&entries, &catalog::render(&ns), LINK_NAME, self.lt.chain); + let ts = crate::package::rfc3339(self.gw.clock.unix_us()); + let hash = joule_moq::receipt::ObjectReceipt::hash_payload(&bytes); + let (bundle, link) = sign_group(&self.gw.signer, self.catalog_chain, g, &[hash], &ts, &ts, self.catalog_link); + self.catalog_link = Some(link); + let _ = self.publisher.send(Emit::Single { alias: CATALOG_ALIAS, group: g, priority: 0, bytes }); + let _ = self.publisher.send(Emit::Single { alias: CATALOG_RECEIPTS_ALIAS, group: g, priority: 0, bytes: bundle }); + } + + fn heads(&self) -> Vec { + let mut v: Vec = self + .packagers + .values() + .map(|p| { + let ns = catalog::media_namespace(&self.p.room.id, &self.owner, p.out.media); + let (groups, head) = p.chain_head(); + TrackHead { track: catalog::track_digest(&ns, catalog::LAYER0), receipt_alias: p.out.receipt_alias, groups, head: head.unwrap_or([0; 32]) } + }) + .collect(); + v.sort_by_key(|t| t.track); + v + } + + /// Sign the next part of the link record, publish it on the link track, and keep it. + fn part(&mut self, last: bool, why: &str) { + let end = self.room_now().max(self.lt.part_start + 1); + let mut account = self.link.account.lock().unwrap().cut(end, last); + account.start_us = self.lt.part_start; + account.end_us = end; + let info = &self.link.info; + let rec = LinkRecord { + room: self.p.room.id, + hop: self.gw.public_key(), + principal: self.p.ad.grant.principal, + grant: self.p.ad.digest, + session: self.p.session, + part: self.lt.part, + parent: self.lt.parent, + protocol: info.protocol.code(), + mode: info.mode.code(), + cipher: info.cipher_bits, + last, + ended: if last { linkrec::ended::code(why) } else { 0 }, + start: self.lt.part_start, + end, + window: info.latency_us.max(1), + account, + tracks: self.heads(), + }; + let bytes = rec.sign(&self.gw.signer.key); + debug_assert!(LinkRecord::verify(&bytes).is_ok(), "the gateway signs records that verify: {:?}: window {} account {:?}", LinkRecord::verify(&bytes), rec.window, rec.account); + self.lt.parent = linkrec::digest(&bytes); + self.lt.part += 1; + self.lt.part_start = end; + let g = match self.lt.group { + Some(g) => g + 1, + None => end, + }; + self.lt.group = Some(g); + let ts = crate::package::rfc3339(self.gw.clock.unix_us()); + let hash = joule_moq::receipt::ObjectReceipt::hash_payload(&bytes); + let (bundle, link) = sign_group(&self.gw.signer, self.lt.chain, g, &[hash], &ts, &ts, self.lt.prev); + self.lt.prev = Some(link); + let _ = self.publisher.send(Emit::Single { alias: LINK_ALIAS, group: g, priority: 0, bytes: bytes.clone() }); + let _ = self.publisher.send(Emit::Single { alias: LINK_RECEIPTS_ALIAS, group: g, priority: 0, bytes: bundle }); + self.gw.sink_link(&bytes); + } + + async fn end(mut self, why: &str) { + // What the demultiplexer still holds. + let rest = self.demux.flush(); + for pes in rest { + let _ = self.on_pes(pes); + } + let mut emits = Vec::new(); + for p in self.packagers.values_mut() { + p.end_group(&mut emits); + } + for e in emits { + let _ = self.publisher.send(e); + } + if self.catalog_group.is_none() { + self.catalog_dirty = true; + } + self.publish_catalog(true); + self.part(true, why); + self.publisher.end(END_GRACE).await; + let end_us = self.room_now(); + let record = self.record(why, end_us); + self.gw.sink.record(&SignedRecord::sign(record, &self.gw.signer.key)); + if let Some(r) = self.delete_reply.take() { + let _ = r.send(()); + } + tracing::info!(session = %self.p.id, "{} session ended: {why}", self.link.info.protocol.name()); + } + + fn record(&self, why: &str, end: u64) -> SessionRecord { + let mut tracks = Vec::new(); + let mut detail = serde_json::Map::new(); + let mut bytes_out = 0; + for p in self.packagers.values() { + let ns = catalog::media_namespace(&self.p.room.id, &self.owner, p.out.media); + let digest = hex::encode(catalog::track_digest(&ns, catalog::LAYER0)); + let base = TrackEntry { kind: 1, track: digest, namespace: catalog::render(&ns), name: catalog::LAYER0.into(), ..Default::default() }; + let c = &p.counts; + let dropped = c.dropped_before_keyframe + c.dropped_too_large + c.dropped_unreadable; + tracks.push(TrackEntry { dir: 1, objects: c.frames_in, bytes: c.bytes_in, dropped_refused: dropped, ..base.clone() }); + tracks.push(TrackEntry { dir: 2, objects: c.objects_out, bytes: c.bytes_out, ..base }); + bytes_out += c.bytes_out; + detail.insert(crate::room::media_name(p.out.media).unwrap_or("media").into(), serde_json::to_value(c).unwrap()); + } + tracks.sort_by(|a, b| (a.kind, &a.track, a.dir).cmp(&(b.kind, &b.track, b.dir))); + detail.insert("demux".into(), serde_json::to_value(&self.demux.counts).unwrap()); + detail.insert("unsupported_streams".into(), (self.unsupported_streams.len() as u64).into()); + detail.insert("link".into(), serde_json::to_value(&self.link.account.lock().unwrap().total).unwrap()); + detail.insert("link_parts".into(), self.lt.part.into()); + let g = &self.p.ad.grant; + let ledger = vec![LedgerEntry { + grant: hex::encode(self.p.ad.digest), + joules_ceiling: g.joules_ceiling, + funds_ceiling: g.funds_ceiling, + currency: String::from_utf8_lossy(&g.currency).trim_matches('\0').to_string(), + sessions: 1, + bytes_in: self.bytes_in, + bytes_out, + subscriber_us: [0; 9], + }]; + SessionRecord { + format: FORMAT.into(), + hop: hex::encode(self.gw.public_key()), + role: 3, + protocol: self.link.info.protocol.name().into(), + session: self.p.id.clone(), + room: hex::encode(self.p.room.id), + principal: self.owner.clone(), + start: self.start_us, + end: end.max(self.start_us + 1), + energy: Energy::unmetered(), + tracks, + sessions: 0, + subscriber_us: [0; 9], + ledger, + ended: why.into(), + detail: serde_json::Value::Object(detail), + } + } +} + +/// A session's rate bound, kbit/s: the configured one; for RIST, at most what keeps the +/// receiver's buffer (`buffer_us`) under 32 768 packets of 1 316 bytes, half the space of +/// RTP's 16-bit sequence numbers (live-contribution §8). +pub fn rate_bound_kbps(protocol: Protocol, configured: u64, buffer_us: u64) -> u64 { + match protocol { + Protocol::Srt => configured, + Protocol::RistSimple | Protocol::RistMain => configured.min(32_768 * 1_316 * 8 / (buffer_us / 1000).max(1)), + } +} + +/// What a POST answers: the session, and how to connect to it. +pub fn answer(gw: &Gateway, p: &Pending, port: Option) -> serde_json::Value { + let host = gw.cfg.contrib.host.clone(); + match p.protocol { + Protocol::Srt => serde_json::json!({ + "session": p.id, + "protocol": "srt", + "stream_id": stream_id(p), + "passphrase": p.passphrase, + "pbkeylen": gw.cfg.contrib.srt.pbkeylen, + "latency_ms": gw.cfg.contrib.srt.latency_ms, + "host": host, + "port": port, + "expires_s": gw.cfg.contrib.session_ttl_s, + }), + Protocol::RistSimple | Protocol::RistMain => serde_json::json!({ + "session": p.id, + "protocol": p.protocol.name(), + "cname": p.id, + "passphrase": p.passphrase, + "aes": if p.passphrase.is_some() { serde_json::json!(gw.cfg.contrib.rist.aes) } else { serde_json::Value::Null }, + "buffer_ms": gw.cfg.contrib.rist.buffer_ms, + "host": host, + "port": port, + "expires_s": gw.cfg.contrib.session_ttl_s, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_rist_sessions_rate_is_bounded_by_its_sequence_space() { + // §8's figure: 32 768 × 1 316 × 8 bits within a buffer of 1 000 ms. + assert_eq!(rate_bound_kbps(Protocol::RistMain, 1_000_000, 1_000_000), 344_981); + assert_eq!(rate_bound_kbps(Protocol::RistSimple, u64::MAX, 100_000), 3_449_815); + // Under the configured bound, the configured one holds; SRT's 32-bit space is not + // bounded so. + assert_eq!(rate_bound_kbps(Protocol::RistMain, 80_000, 1_000_000), 80_000); + assert_eq!(rate_bound_kbps(Protocol::Srt, 1_000_000, 10_000_000), 1_000_000); + // A buffer under a millisecond is taken as one. + assert_eq!(rate_bound_kbps(Protocol::RistMain, u64::MAX, 0), 32_768 * 1_316 * 8); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/frame.rs b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/frame.rs index 046ee611b..12524d2be 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-ingest/src/frame.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-ingest/src/frame.rs @@ -11,6 +11,18 @@ //! | AV1 | the AV1 RTP payload format, v1.0.0 | `wai.video.av1` | one temporal unit in SPEC §5's AV1 framing | //! | Opus | RFC 7587 | `wai.audio.opus.packet` | one Opus packet (RFC 6716 §3) | //! +//! Enhanced RTMP ([`crate::rtmp`]) carries four more, whose payloads its reader builds +//! (`extensions/live-ingest.md` §12): HEVC (`wai.video.hevc`, an Annex B access unit), VVC +//! (`wai.video.vvc`, mapped and not carried in this revision), AAC (`wai.audio.aac`, one +//! ADTS frame) and FLAC (`wai.audio.flac`, a FLAC stream). An MPEG transport stream over SRT +//! or RIST (`extensions/live-ingest.md` §13, `extensions/live-contribution.md`) carries H.264 +//! as above, and two of those: +//! +//! | codec | stream type | capability | one object's payload | +//! |---|---|---|---| +//! | HEVC | 0x24 | `wai.video.hevc` | one access unit, Annex B byte stream, as the PES carries it | +//! | AAC | 0x0F | `wai.audio.aac` | one ADTS frame (ISO/IEC 13818-7) | +//! //! The RTP depacketisation itself is the WebRTC stack's (str0m), which hands over each //! frame whole: an H.264 access unit with a four-byte start code before every NAL unit, //! a VP8 or VP9 frame without its payload descriptor, the OBUs of an AV1 temporal unit @@ -31,10 +43,23 @@ pub enum Codec { Vp9, Av1, Opus, + /// From Enhanced RTMP or a transport stream only: the WebRTC stack here carries none of + /// these four. + Hevc, + Vvc, + Aac, + Flac, } impl Codec { - pub const ALL: [Codec; 5] = [Codec::Avc, Codec::Vp8, Codec::Vp9, Codec::Av1, Codec::Opus]; + /// The codecs a WebRTC session carries (WHIP and WHEP). + pub const WEBRTC: [Codec; 5] = [Codec::Avc, Codec::Vp8, Codec::Vp9, Codec::Av1, Codec::Opus]; + /// Every codec an object of this gateway may carry. + pub const ALL: [Codec; 9] = [Codec::Avc, Codec::Vp8, Codec::Vp9, Codec::Av1, Codec::Opus, Codec::Hevc, Codec::Vvc, Codec::Aac, Codec::Flac]; + + pub fn webrtc(self) -> bool { + Codec::WEBRTC.contains(&self) + } pub fn from_str0m(c: str0m::format::Codec) -> Option { use str0m::format::Codec as C; @@ -48,14 +73,16 @@ impl Codec { } } - pub fn to_str0m(self) -> str0m::format::Codec { + /// The WebRTC stack's codec; `None` for a codec WebRTC does not carry here. + pub fn to_str0m(self) -> Option { use str0m::format::Codec as C; match self { - Codec::Avc => C::H264, - Codec::Vp8 => C::Vp8, - Codec::Vp9 => C::Vp9, - Codec::Av1 => C::Av1, - Codec::Opus => C::Opus, + Codec::Avc => Some(C::H264), + Codec::Vp8 => Some(C::Vp8), + Codec::Vp9 => Some(C::Vp9), + Codec::Av1 => Some(C::Av1), + Codec::Opus => Some(C::Opus), + Codec::Hevc | Codec::Vvc | Codec::Aac | Codec::Flac => None, } } @@ -67,6 +94,10 @@ impl Codec { Codec::Vp9 => "wai.video.vp9", Codec::Av1 => "wai.video.av1", Codec::Opus => "wai.audio.opus.packet", + Codec::Hevc => "wai.video.hevc", + Codec::Vvc => "wai.video.vvc", + Codec::Aac => "wai.audio.aac", + Codec::Flac => "wai.audio.flac", } } @@ -82,11 +113,15 @@ impl Codec { Codec::Vp9 => "vp9", Codec::Av1 => "av1", Codec::Opus => "opus", + Codec::Hevc => "hevc", + Codec::Vvc => "vvc", + Codec::Aac => "aac", + Codec::Flac => "flac", } } pub fn is_video(self) -> bool { - self != Codec::Opus + !matches!(self, Codec::Opus | Codec::Aac | Codec::Flac) } /// The manifest's `media`. @@ -94,7 +129,7 @@ impl Codec { if self.is_video() { "video" } else { "audio" } } - /// The RTP clock rate every one of these payload formats uses. + /// The RTP clock rate every one of the WebRTC payload formats uses. pub fn clock_rate(self) -> u64 { if self.is_video() { 90_000 } else { 48_000 } } @@ -107,6 +142,10 @@ impl Codec { Codec::Vp9 => "vp09", Codec::Av1 => "av01", Codec::Opus => "opus", + Codec::Hevc => "hvc1", + Codec::Vvc => "vvc1", + Codec::Aac => "mp4a", + Codec::Flac => "flac", } } } @@ -153,6 +192,30 @@ pub struct Header { pub codec: Option, /// Audio: the channel count the first packet states. pub channels: u8, + /// Audio: the sampling rate its configuration states, in Hz; 0 where it states none. + pub rate: u32, +} + +impl Header { + /// This header, with what it leaves unstated taken from `hint`: a size of 0, no codec + /// string, no channel count or rate. + pub fn or_hint(mut self, hint: Option<&Header>) -> Header { + if let Some(h) = hint { + if self.width == 0 || self.height == 0 { + (self.width, self.height) = (h.width, h.height); + } + if self.codec.is_none() { + self.codec = h.codec.clone(); + } + if self.channels == 0 { + self.channels = h.channels; + } + if self.rate == 0 { + self.rate = h.rate; + } + } + self + } } /// H.264: the codec string from the first SPS of an access unit (`avc1.PPCCLL`: its @@ -160,7 +223,7 @@ pub struct Header { pub fn avc_header(au: &[u8]) -> Option
{ for nal in annexb_nals(au) { if nal.first().map(|h| h & 0x1f) == Some(7) && nal.len() >= 4 { - return Some(Header { width: 0, height: 0, codec: Some(format!("avc1.{:02X}{:02X}{:02X}", nal[1], nal[2], nal[3])), channels: 0 }); + return Some(Header { width: 0, height: 0, codec: Some(format!("avc1.{:02X}{:02X}{:02X}", nal[1], nal[2], nal[3])), channels: 0, rate: 0 }); } } None @@ -195,6 +258,19 @@ pub fn avc_is_idr(au: &[u8]) -> bool { annexb_nals(au).iter().any(|n| n.first().map(|h| h & 0x1f) == Some(5)) } +/// Whether an H.265 access unit holds an IRAP picture (NAL unit types 16 to 23, ITU-T +/// H.265 §7.4.2.2): the first byte of its two-byte header holds the type in bits 1 to 6. +pub fn hevc_is_irap(au: &[u8]) -> bool { + annexb_nals(au).iter().any(|n| n.first().is_some_and(|h| (16..=23).contains(&((h >> 1) & 0x3f)))) +} + +/// AAC: the codec string (`mp4a.40.