From 018c6a89b69e51fa27acf5c95234f9b52e69656c Mon Sep 17 00:00:00 2001 From: Max Albrecht <1@178.is> Date: Wed, 19 Aug 2026 03:59:54 +0200 Subject: [PATCH 01/24] =?UTF-8?q?E:=20dexd=20=E2=80=94=20the=20player,=20i?= =?UTF-8?q?ts=20documentation=20and=20the=20checks=20that=20keep=20both=20?= =?UTF-8?q?honest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dexd plays one HEVC video artwork in an endless loop on a Raspberry Pi, with no visible break at the loop point, on a machine that is switched off at the mains. It feeds libmpv one stream that never ends, so playback never reaches the end of the file and never seeks. The player. The video, its sidecar and the exhibit config live together in /opt/dex on the card's data partition; the package installs no config. The sidecar binds the frame rate and a checksum to the video, and dexd refuses to start rather than guess either. Before opening anything it checks the display mode, the boot options and the start of the video, and every refusal names the fix. While playing it checks that playback advances, recovers in place, writes a heartbeat to the system log, and lets the service manager restart it if it stops responding. It ships as a Debian package for arm64 with four commands — dexd(1), dex-sidecar(1), dex-exhibit-apply(1), dex-wait-hdmi(1) — and their man pages. Source under MIT-0; the shipped binary links libmpv and is a GPL-3+ combined work. The empty packages/dexd submodule, which pointed at a repository that no longer exists, is replaced by the crate itself. The documentation. docs/guides is for a venue technician who can use a terminal: what dexd is, building a card, preparing a video, configuring the exhibit, running and troubleshooting, and a reference of every key, option, exit code and message. docs/design is for a developer who has never seen the project: the architecture, the endless stream, the startup checks, failure handling, the service unit, the sidecar, the exhibit config, packaging, continuous integration, building and testing, what Raspberry Pi hardware can do, the roadmap, and the measurement record the other pages cite. docs/glossary.md holds the terms the documentation may use without explaining them, each one reviewed and approved. The rules and the checks. AGENTS.md says how anything an outsider reads is written — the two audiences, the tone, what a comment is for, the names that were decided and the words that were retired. scripts/docs-lint.mjs checks what a machine can check, against the glossary, a table of retired words and an allowlist where every exception carries a reason; it has 83 tests of its own and runs in CI over the documentation, the crate, the man pages and the READMEs. CODEOWNERS routes the glossary and the rules to the project owner. One CI workflow with no path filters builds the package in a debian:trixie container on an arm64 runner, runs the test suite, cargo-deny, the shell and unit checks, REUSE, the writing check and an install, remove and purge cycle, and a single required check reads their results and decides. --- .github/CODEOWNERS | 6 + .github/workflows/dexd.yml | 536 +++++ .gitmodules | 3 - AGENTS.md | 222 ++ CLAUDE.md | 1 + README.md | 134 +- docs/design/architecture.md | 215 ++ docs/design/ci.md | 205 ++ docs/design/development.md | 157 ++ docs/design/endless-stream.md | 98 + docs/design/exhibit-config.md | 230 ++ docs/design/failure-handling.md | 204 ++ docs/design/measurements.md | 327 +++ docs/design/packaging.md | 167 ++ docs/design/pi-capability.md | 333 +++ docs/design/roadmap.md | 139 ++ docs/design/service-unit.md | 180 ++ docs/design/sidecar.md | 155 ++ docs/design/startup-checks.md | 127 ++ docs/glossary.md | 1181 ++++++++++ docs/guides/build-player-card.md | 169 ++ docs/guides/configure-exhibit.md | 170 ++ docs/guides/prepare-video.md | 148 ++ docs/guides/reference.md | 301 +++ docs/guides/run-check-troubleshoot.md | 153 ++ docs/guides/what-dexd-is.md | 88 + docs/lint-allow.txt | 49 + docs/lint-coinages.tsv | 90 + packages/dexd | 1 - packages/dexd/.gitignore | 3 + packages/dexd/Cargo.lock | 230 ++ packages/dexd/Cargo.toml | 124 + packages/dexd/LICENSE | 75 + packages/dexd/LICENSES/MIT-0.txt | 16 + packages/dexd/README.md | 107 + packages/dexd/REUSE.toml | 23 + packages/dexd/build.rs | 79 + packages/dexd/deny.toml | 51 + packages/dexd/deploy/changelog | 18 + packages/dexd/deploy/dex-wait-hdmi | 32 + packages/dexd/deploy/dexd.service | 76 + packages/dexd/deploy/lintian-overrides | 21 + .../dexd/deploy/maintainer-scripts/postinst | 66 + .../dexd/deploy/maintainer-scripts/postrm | 14 + packages/dexd/deploy/man/dex-exhibit-apply.1 | 376 ++++ packages/dexd/deploy/man/dex-sidecar.1 | 239 ++ packages/dexd/deploy/man/dex-wait-hdmi.1 | 164 ++ packages/dexd/deploy/man/dexd.1 | 388 ++++ packages/dexd/src/bin/dex-exhibit-apply.rs | 308 +++ packages/dexd/src/bin/dex-sidecar.rs | 678 ++++++ packages/dexd/src/chunk.rs | 183 ++ packages/dexd/src/exhibit.rs | 2003 +++++++++++++++++ packages/dexd/src/ffi_consts.rs | 67 + packages/dexd/src/health.rs | 469 ++++ packages/dexd/src/heartbeat.rs | 478 ++++ packages/dexd/src/lib.rs | 18 + packages/dexd/src/main.rs | 1764 +++++++++++++++ packages/dexd/src/nal.rs | 191 ++ packages/dexd/src/sha256.rs | 101 + packages/dexd/src/sidecar.rs | 519 +++++ packages/dexd/src/watchdog.rs | 563 +++++ packages/dexd/tests/cli.rs | 1408 ++++++++++++ packages/dexd/tests/ffi_constants.rs | 141 ++ packages/dexd/tests/sidecar_write.rs | 457 ++++ scripts/README-docs-lint.md | 131 ++ scripts/docs-lint.mjs | 1934 ++++++++++++++++ scripts/docs-lint.test.mjs | 1099 +++++++++ 67 files changed, 20336 insertions(+), 67 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/dexd.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/design/architecture.md create mode 100644 docs/design/ci.md create mode 100644 docs/design/development.md create mode 100644 docs/design/endless-stream.md create mode 100644 docs/design/exhibit-config.md create mode 100644 docs/design/failure-handling.md create mode 100644 docs/design/measurements.md create mode 100644 docs/design/packaging.md create mode 100644 docs/design/pi-capability.md create mode 100644 docs/design/roadmap.md create mode 100644 docs/design/service-unit.md create mode 100644 docs/design/sidecar.md create mode 100644 docs/design/startup-checks.md create mode 100644 docs/glossary.md create mode 100644 docs/guides/build-player-card.md create mode 100644 docs/guides/configure-exhibit.md create mode 100644 docs/guides/prepare-video.md create mode 100644 docs/guides/reference.md create mode 100644 docs/guides/run-check-troubleshoot.md create mode 100644 docs/guides/what-dexd-is.md create mode 100644 docs/lint-allow.txt create mode 100644 docs/lint-coinages.tsv delete mode 160000 packages/dexd create mode 100644 packages/dexd/.gitignore create mode 100644 packages/dexd/Cargo.lock create mode 100644 packages/dexd/Cargo.toml create mode 100644 packages/dexd/LICENSE create mode 100644 packages/dexd/LICENSES/MIT-0.txt create mode 100644 packages/dexd/README.md create mode 100644 packages/dexd/REUSE.toml create mode 100644 packages/dexd/build.rs create mode 100644 packages/dexd/deny.toml create mode 100644 packages/dexd/deploy/changelog create mode 100755 packages/dexd/deploy/dex-wait-hdmi create mode 100644 packages/dexd/deploy/dexd.service create mode 100644 packages/dexd/deploy/lintian-overrides create mode 100755 packages/dexd/deploy/maintainer-scripts/postinst create mode 100755 packages/dexd/deploy/maintainer-scripts/postrm create mode 100644 packages/dexd/deploy/man/dex-exhibit-apply.1 create mode 100644 packages/dexd/deploy/man/dex-sidecar.1 create mode 100644 packages/dexd/deploy/man/dex-wait-hdmi.1 create mode 100644 packages/dexd/deploy/man/dexd.1 create mode 100644 packages/dexd/src/bin/dex-exhibit-apply.rs create mode 100644 packages/dexd/src/bin/dex-sidecar.rs create mode 100644 packages/dexd/src/chunk.rs create mode 100644 packages/dexd/src/exhibit.rs create mode 100644 packages/dexd/src/ffi_consts.rs create mode 100644 packages/dexd/src/health.rs create mode 100644 packages/dexd/src/heartbeat.rs create mode 100644 packages/dexd/src/lib.rs create mode 100644 packages/dexd/src/main.rs create mode 100644 packages/dexd/src/nal.rs create mode 100644 packages/dexd/src/sha256.rs create mode 100644 packages/dexd/src/sidecar.rs create mode 100644 packages/dexd/src/watchdog.rs create mode 100644 packages/dexd/tests/cli.rs create mode 100644 packages/dexd/tests/ffi_constants.rs create mode 100644 packages/dexd/tests/sidecar_write.rs create mode 100644 scripts/README-docs-lint.md create mode 100644 scripts/docs-lint.mjs create mode 100644 scripts/docs-lint.test.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..899f352 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# The glossary and the writing rules are approved by the project owner, entry by entry. +# Branch protection requires a code-owner review for these paths, so no change lands without him. +docs/glossary.md @eins78 +docs/lint-coinages.tsv @eins78 +docs/lint-allow.txt @eins78 +AGENTS.md @eins78 diff --git a/.github/workflows/dexd.yml b/.github/workflows/dexd.yml new file mode 100644 index 0000000..bcece07 --- /dev/null +++ b/.github/workflows/dexd.yml @@ -0,0 +1,536 @@ +# Build the dexd Debian package for the Raspberry Pi players. +# +# The package derives `Depends:` from the shared libraries the binary links +# (dpkg-shlibdeps, driven by `depends = "$auto"` in Cargo.toml), so apt refuses +# a libmpv version mismatch when the package is installed. That holds only while +# the build environment matches the target: `ubuntu-24.04-arm` gives native +# arm64, the `debian:trixie` container gives Debian's libraries. Re-check the +# pairing when the players move to a new Debian release: +# ssh '. /etc/os-release; echo $VERSION_CODENAME; dpkg -s libmpv2' +# +# rustc and cargo come from Debian, not rustup, because the package is built for +# Debian and must build with the compiler the players have (trixie ships 1.85). +# A dependency that needs a newer compiler cannot be adopted; the `rust-version` +# floor in Cargo.toml states the same limit and this workflow enforces it. See +# docs/design/ci.md. +name: dexd deb + +# Manual dispatch only appears in the Actions interface for workflows that are on +# the default branch. +# +# No path filters: a filtered workflow that does not run reports nothing, and a +# required check that never reports blocks every merge. The workflow always runs, +# the `changes` job decides what is relevant, and `gate` reports the one verdict +# branch protection requires. +on: + workflow_dispatch: + push: + branches: ["main", "experiment/**", "feature/**"] + tags: ["dexd-v*"] + pull_request: + +# One run per ref; a superseded run is cancelled. Grouping by ref keeps a tag +# build, which produces the release artifact, from being cancelled by a push to a +# branch. +concurrency: + group: dexd-deb-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CRATE_DIR: packages/dexd + # build.rs compiles this into the binary as its build identity. An environment + # variable, because `rerun-if-changed` on a path that did not exist when the + # cached build ran counts as unchanged: with a warm target/ cache the build-id + # file is written and then ignored, and the package reports `(nogit)`. Cargo + # compares the value of a `rerun-if-env-changed` variable, so a changed value is + # always noticed. + DEX_BUILD_ID: ${{ github.sha }} + +jobs: + # Which parts of the tree changed, computed here so that every job and `gate` can + # read the answer. A few lines of `git diff` against the base keep a third-party + # action out of a workflow that packages software for devices. + changes: + runs-on: ubuntu-latest + outputs: + crate: ${{ steps.detect.outputs.crate }} + docs: ${{ steps.detect.outputs.docs }} + reason: ${{ steps.detect.outputs.reason }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need history to diff against a base + - id: detect + run: | + set -eu + # A tag build or a manual dispatch runs everything: a release is built from the + # whole tree, and a person pressing the button means run it. + case "${{ github.event_name }}" in + workflow_dispatch) echo "crate=true" >> "$GITHUB_OUTPUT"; echo "docs=true" >> "$GITHUB_OUTPUT" + echo "reason=manual dispatch" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + case "${{ github.ref }}" in + refs/tags/*) echo "crate=true" >> "$GITHUB_OUTPUT"; echo "docs=true" >> "$GITHUB_OUTPUT" + echo "reason=tag build" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + else + base="${{ github.event.before }}" + fi + # A new branch, a force-push or a first commit gives an unusable base (all zeros, + # or an object this checkout does not have). Run everything in that case, so an + # unknown diff cannot skip a real change. + if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "$base^{commit}" 2>/dev/null; then + echo "crate=true" >> "$GITHUB_OUTPUT"; echo "docs=true" >> "$GITHUB_OUTPUT" + echo "reason=no usable diff base, running everything" >> "$GITHUB_OUTPUT"; exit 0 + fi + + changed=$(git diff --name-only "$base" HEAD) + echo "changed files:"; echo "$changed" | sed 's/^/ /' + crate=false; docs=false; reason="" + if echo "$changed" | grep -qE '^(packages/dexd/|\.github/workflows/dexd\.yml$)'; then + crate=true; reason="crate or this workflow changed" + fi + # The documentation check covers the public docs, the writing rules, the glossary + # and the lint tool. + if echo "$changed" | grep -qE '^(docs/|README\.md$|AGENTS\.md$|CLAUDE\.md$|scripts/docs-lint|packages/dexd/|\.github/workflows/dexd\.yml$)'; then + docs=true; reason="${reason:+$reason; }docs, rules or the docs lint changed" + fi + echo "crate=$crate" >> "$GITHUB_OUTPUT" + echo "docs=$docs" >> "$GITHUB_OUTPUT" + echo "reason=${reason:-nothing relevant changed}" >> "$GITHUB_OUTPUT" + + build: + needs: changes + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + container: debian:trixie + + steps: + - name: Install build dependencies + run: | + set -eux + apt-get update + # libmpv-dev is what the crate links; dpkg-dev provides dpkg-shlibdeps, which + # turns those links into Depends. rustc and cargo come from Debian, not rustup. + # ffmpeg supplies ffmpeg and ffprobe for tests/sidecar_write.rs, which encodes + # real HEVC streams to run `dex-sidecar write` against and skips itself when + # they are missing. + apt-get install -y --no-install-recommends \ + build-essential pkg-config ca-certificates git \ + rustc cargo rust-clippy libmpv-dev dpkg-dev lintian ffmpeg + # Record the versions built and linked against. When a .deb refuses to install on + # a device, compare these lines with that device's own `dpkg -s libmpv2` and + # `rustc --version`. + dpkg -s libmpv2 | grep -E '^(Package|Version):' + rustc --version + cargo --version + # Fails the step when ffprobe is missing, so the sidecar-writer tests cannot skip + # and still report a pass. + ffprobe -version | head -n1 + + - uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/registry + ~/.cargo/git + ${{ env.CRATE_DIR }}/target + key: dexd-deb-${{ hashFiles('packages/dexd/Cargo.lock') }} + restore-keys: dexd-deb- + + # Pinned to the 2.x line: cargo-deb 3.7 uses let-chains and needs rustc >= 1.88, + # which trixie's rustc (printed in the log above) does not meet. Raise the pin + # only when that rustc satisfies the newer cargo-deb, or when the build stops + # using Debian's rustc. + # + # The guard tests the file: cargo installs to ~/.cargo/bin, which is not on + # $PATH here (cargo comes from apt, and no rustup adds it), so on a cache hit + # `command -v cargo-deb` reports it missing while `cargo install` exits 101 with + # the binary already in place. `cargo deb` itself works either way — cargo + # searches $CARGO_HOME/bin for subcommands. + - name: Install cargo-deb + run: test -x "$HOME/.cargo/bin/cargo-deb" || cargo install --locked cargo-deb --version "^2" + + # The full suite runs here because this container has libmpv; a checkout on macOS + # cannot link the binary and can only run `cargo test --lib`. + - name: Clippy + working-directory: ${{ env.CRATE_DIR }} + run: cargo clippy --all-targets -- -D warnings + + - name: Test + working-directory: ${{ env.CRATE_DIR }} + run: cargo test --release + + # Runs the forced-recovery test against a real mpv core. It carries #[ignore] in + # tests/cli.rs so a plain `cargo test` never picks it up; this step is where it runs. + # + # The grep is the assertion: libtest exits 0 when a filter matches nothing, so a + # renamed or deleted test would leave this step passing having run nothing. + # Requiring "1 passed" turns that into a failure. + # + # It does not exercise hwdec=drm or the DRM plane swap (no GPU here); see + # docs/design/failure-handling.md and the test's doc comment. + - name: C1 live-fire (forced recovery vs real mpv) + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + out=$(cargo test --release --test cli -- --ignored --exact \ + force_recovery_survives_against_real_mpv 2>&1) || { echo "$out"; exit 1; } + echo "$out" + echo "$out" | grep -q '1 passed' \ + || { echo "::error::C1 gate did not actually run (filter matched nothing?)"; exit 1; } + + # Every build must produce a distinct, ordered version, or apt reports the + # package as already installed and does nothing while `dpkg -l` shows the + # expected version and the device keeps running the older binary. + # + # Scheme: +g. The run number leads because dpkg compares + # digit runs numerically, so ordering follows time. A commit-only revision does + # not order: `dpkg --compare-versions` sorts 0.1.0-1+gzz999999 above + # 0.1.0-1+g000aaaaa, so an older build can outrank a newer one and apt refuses + # the upgrade as a downgrade. + - name: Build package + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + short=$(printf '%s' "$GITHUB_SHA" | cut -c1-12) + cargo deb --deb-revision "${GITHUB_RUN_NUMBER}+g${short}" + + # Print the derived Depends into the log and assert that libmpv is in it. If + # dpkg-shlibdeps stops resolving libmpv, the package still builds and still + # installs, onto a device that has no libmpv, and fails at runtime. + - name: Verify derived dependencies + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + deb=$(find target/debian -name '*.deb' -print -quit) + test -n "$deb" + dpkg-deb --field "$deb" Package Version Architecture Depends + dpkg-deb --contents "$deb" + dpkg-deb --field "$deb" Depends | grep -q libmpv \ + || { echo "::error::Depends does not mention libmpv — dpkg-shlibdeps did not resolve it"; exit 1; } + # The behaviour floor, which dpkg-shlibdeps cannot derive: from the linked + # symbols alone the answer is `libmpv2 (>= 0.19.0)`, the oldest version exporting + # them, while dexd needs libmpv 0.40 behaviour (drmprime-overlay, and `END_FILE` + # (reason=stop) on a loadfile replace). Asserted here because deleting the + # explicit constraint from Cargo.toml still produces a package that builds and + # installs. + dpkg-deb --field "$deb" Depends | grep -qE 'libmpv2 \(>= 0\.(4[0-9]|[5-9][0-9])' \ + || { echo "::error::Depends lost its explicit libmpv2 >= 0.40 floor"; exit 1; } + # The binary must be able to say which build it is. `(nogit)` means build.rs + # found neither the build-id file nor a usable git, and the installed package + # cannot be traced back to a commit. + # `dpkg-deb -x` instead of piping --fsys-tarfile into tar: the tarfile's members + # carry no './' prefix, so `tar -xO ./usr/bin/dexd` finds nothing and exits 2. + extract=$(mktemp -d) + dpkg-deb -x "$deb" "$extract" + test -x "$extract/usr/bin/dexd" + # `--version` writes to stderr, so 2>&1 is required: without it $ver is empty, + # the `case` below matches nothing, and the check reports success having observed + # nothing. + ver=$("$extract/usr/bin/dexd" --version 2>&1 | head -1) + echo "packaged binary reports: $ver" + case "$ver" in + *nogit*) echo "::error::packaged binary reports (nogit) — build identity not stamped"; exit 1 ;; + esac + # A positive assertion, because "does not contain nogit" is also true of the + # empty string: the build identity must be 12 hex characters and must match the + # commit being built. + # `cut`, because run steps in a `container:` job execute under /bin/sh (dash on + # trixie), where bash substring expansion is a "Bad substitution" error. + short=$(printf '%s' "$GITHUB_SHA" | cut -c1-12) + echo "$ver" | grep -qE "\($short(\+dirty)?\)" \ + || { echo "::error::version '$ver' does not carry this commit ($short)"; exit 1; } + + # The package version must differ per build, beyond the binary's internal string: + # apt decides whether to install by version alone. + pkgver=$(dpkg-deb --field "$deb" Version) + echo "package version: $pkgver" + case "$pkgver" in + *"$GITHUB_RUN_NUMBER+g$short"*) : ;; + *) echo "::error::package version '$pkgver' lacks the run number/commit — apt will refuse to reinstall it"; exit 1 ;; + esac + + # Policy check on the built package. `--fail-on error,warning` makes a finding + # fail the run. Accepted tags live in deploy/lintian-overrides with their + # reasons, so silencing one is a reviewable diff. See docs/design/packaging.md. + - name: Lintian + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + deb=$(find target/debian -name '*.deb' -print -quit) + lintian --tag-display-limit 0 --fail-on error,warning "$deb" + + - uses: actions/upload-artifact@v4 + with: + name: dexd-deb + path: ${{ env.CRATE_DIR }}/target/debian/*.deb + if-no-files-found: error + + # Static checks on the files that ship without being compiled: the systemd unit + # and the shell scripts. A separate job, so it reports independently of the + # package build and still runs when the build breaks. + lint: + needs: changes + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + container: debian:trixie + steps: + - name: Install linters + run: | + set -eux + apt-get update + # devscripts provides checkbashisms; systemd provides systemd-analyze. + apt-get install -y --no-install-recommends \ + shellcheck devscripts systemd git ca-certificates reuse + + - uses: actions/checkout@v4 + + # systemd ignores a directive it does not recognise in a given section. On a typo + # or a key in the wrong section, systemd does not report it: the unit starts and + # does less than it says. This step reports it. + # + # The two "Command ... is not executable" notices are expected: those binaries + # live in the package, which is not installed in this container. + - name: systemd-analyze verify + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + out=$(systemd-analyze verify deploy/dexd.service 2>&1 || true) + echo "$out" + # Fail on anything other than the expected not-executable notices. + if echo "$out" | grep -vE 'is not executable|^$' | grep -q .; then + echo "::error::systemd-analyze reported problems with dexd.service" + echo "$out" | grep -vE 'is not executable|^$' + exit 1 + fi + + # Maintainer scripts run as root on every device, under /bin/sh (dash on Debian). + # A bashism there fails the install on the device. + - name: Shell checks + working-directory: ${{ env.CRATE_DIR }} + run: | + set -eux + shellcheck deploy/dex-wait-hdmi deploy/maintainer-scripts/* + checkbashisms deploy/maintainer-scripts/* + + # REUSE (reuse.software): every file carries copyright and licence information + # machine-readably, which is what a distribution packager needs to package dex. + # + # Scoped to the crate with --root. Repo-wide compliance waits on the + # GPL-inherited packages (pi-gen, dex-os), whose licensing is unsettled. See + # docs/design/packaging.md. + - name: REUSE lint + working-directory: ${{ env.CRATE_DIR }} + run: reuse --root . lint + + # The dependency policy as a check: advisories, a trimmed licence allow-list and + # a ban on the procedural-macro toolchain. deny.toml holds the rules; see + # docs/design/packaging.md. + # + # Runs on the bare runner with rustup, outside the trixie container, because the + # Debian-compiler rule binds what builds the shipped artifact. cargo-deny + # produces nothing that ships and needs a newer rustc than trixie has; cargo-deb + # builds the package, which is why that one is pinned instead. + deny: + needs: changes + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + - name: Install cargo-deny + run: | + set -eux + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: cargo deny check + working-directory: ${{ env.CRATE_DIR }} + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo install --locked cargo-deny + cargo deny check + + # Install, remove and purge against a real dpkg: maintainer scripts that fail, + # files that outlive a purge, a service user never created. No static linter + # reaches this class of bug. + # + # piuparts is not packaged for arm64 on this runner; the container is the throwaway + # environment the cycle needs, so it runs directly. See docs/design/ci.md. + lifecycle: + if: needs.changes.outputs.crate == 'true' + runs-on: ubuntu-24.04-arm + container: debian:trixie + needs: [changes, build] + steps: + - uses: actions/download-artifact@v4 + with: + name: dexd-deb + + - name: Install / remove / purge + run: | + set -eux + apt-get update + deb=$(find . -name '*.deb' -print -quit); test -n "$deb" + + # Snapshot the filesystem so a purge can be checked for leftovers. + snap() { find / -xdev \( -path /proc -o -path /sys -o -path /run -o -path /tmp \ + -o -path /var/log -o -path /var/lib/apt -o -path /var/cache \ + -o -path /var/lib/dpkg -o -path /github \) -prune -o -print 2>/dev/null | sort; } + + # `apt install ./dexd.deb` pulls in libmpv2 with everything apt Recommends + # alongside it (aria2, yt-dlp), plus systemd and dbus for the unit; README.md + # documents this same command for a real install. systemd, dbus and policykit are + # Debian-protected: they never autoremove. Their own postinst scripts create + # state (a machine-id, the systemd catalog, enablement markers) that no dpkg file + # list mentions, so a path check cannot tell it apart from a file dexd's postrm + # did not remove. + # + # A device already carries those packages in its base image. This first + # install/remove/purge/autoremove cycle runs unobserved to reach the same state, + # so the "before" snapshot starts where a device does and the leftovers diff at + # the end compares like with like. + apt-get install -y "./${deb#./}" + apt-get remove -y dexd + apt-get purge -y dexd + apt-get autoremove --purge -y + + # Snapshots go to /tmp: snap() walks the whole filesystem, so a snapshot written + # to / would show up in the next snapshot as a new file. /tmp is already in + # snap()'s prune list, for the same reason. + snap > /tmp/before.txt + + # --- the real, asserted run --- + apt-get install -y "./${deb#./}" + + # --- postinst did what it promises --- + test -x /usr/bin/dexd + test -x /usr/bin/dex-wait-hdmi + getent passwd dex # the service user exists + + # postinst joins dex only to groups that already exist (its own + # `getent group "$g"` guard). A device has both video and render; this minimal + # debian:trixie container has no udev, so render is absent here and membership in + # it cannot be asserted. + # + # video is a static base-passwd group present in every Debian environment, so its + # existence, and dex's membership in it, are asserted unconditionally below. That + # keeps one real assertion in force if a future container also lacks render. + getent group video >/dev/null \ + || { echo "::error::video group unexpectedly absent from this container -- the membership check below would be vacuous"; exit 1; } + for g in video render; do + if getent group "$g" >/dev/null; then + id -nG dex | grep -qw "$g" \ + || { echo "::error::postinst did not add dex to the existing '$g' group"; exit 1; } + else + echo "group '$g' does not exist in this container (expected: no udev here) -- skipping membership check" + fi + done + + test -d /opt/dex # asset directory created + test -f /lib/systemd/system/dexd.service + test -f /usr/share/man/man1/dexd.1.gz + # The package ships no video asset: the video is content, not part of the software. + test ! -e /opt/dex/loop.265 + # It ships no exhibit config either, and no /etc/dex. The exhibit config lives + # beside the video in /opt/dex, where a technician writes it. + test ! -e /etc/dex + test ! -e /opt/dex/exhibit.yaml + test ! -e /opt/dex/exhibit.json + + apt-get remove -y dexd + test ! -e /usr/bin/dexd # binary gone + getent passwd dex # the service user is kept on purpose + test -d /opt/dex # the asset directory is kept on purpose + + apt-get purge -y dexd + test ! -e /lib/systemd/system/dexd.service + getent passwd dex # still retained after purge + test -d /opt/dex + test ! -e /etc/dex # never created, so nothing to purge + + apt-get autoremove --purge -y + + # --- leftovers --- + # Two leftovers are intended, both from postrm: /opt/dex holds the video, its + # sidecar and the exhibit config, which the package never shipped, and the `dex` + # user may be named by anything the technician wrote. A third leftover is a bug, + # so this step diffs the two snapshots rather than checking only the exit code. + # The "before" snapshot follows the unobserved cycle above, so apt's own state + # cancels out. + # + # Temp files, because run steps in a `container:` job execute under /bin/sh (dash + # on trixie), which has no process substitution for `diff <(...) <(...)`. + snap > /tmp/after.txt + grep -v '^/opt/dex' /tmp/before.txt > /tmp/before.filtered.txt + grep -v '^/opt/dex' /tmp/after.txt > /tmp/after.filtered.txt + if ! diff /tmp/before.filtered.txt /tmp/after.filtered.txt > /tmp/leftovers.diff; then + echo "::error::purge left files behind beyond the two intended:" + cat /tmp/leftovers.diff + exit 1 + fi + echo "lifecycle OK — install, remove and purge all behave as designed" + + # The writing check: every file an outsider reads must pass + # scripts/docs-lint.mjs (plan codes, private references, coinages, caps emphasis, + # first person, dates as structure — see AGENTS.md). The crate's comments, + # messages and man pages are in scope, so a new comment is checked like a new + # page. An exception needs an entry with a reason in docs/lint-allow.txt. + docs-lint: + needs: changes + if: needs.changes.outputs.docs == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Lint tool self-test + run: node --test scripts/docs-lint.test.mjs + - name: Lint the public docs and the rules + run: node scripts/docs-lint.mjs --coinages docs/lint-coinages.tsv docs AGENTS.md README.md packages/dexd + + # The one check to mark required in branch protection. It runs with + # `if: always()`, so it always reports: a check that can be skipped can never be + # required. It decides in shell, which lets it tell a legitimate skip (nothing + # relevant changed) from a failure and from a cancellation. Anything further to + # assert about a run belongs here. + gate: + name: required + if: always() + needs: [changes, build, lint, deny, lifecycle, docs-lint] + runs-on: ubuntu-latest + steps: + - name: Decide + run: | + set -eu + printf '%s' '${{ toJSON(needs) }}' > /tmp/needs.json + echo "relevant: crate=${{ needs.changes.outputs.crate }} docs=${{ needs.changes.outputs.docs }} (${{ needs.changes.outputs.reason }})" + jq -r 'to_entries[] | " \(.key): \(.value.result)"' /tmp/needs.json + + # failure or cancelled is a hard no. `skipped` is acceptable only when `changes` + # said nothing relevant changed; a job skipped because a dependency failed must + # not pass here. + if jq -e 'any(.[]; .result == "failure" or .result == "cancelled")' /tmp/needs.json >/dev/null; then + echo "::error::a required job failed or was cancelled"; exit 1 + fi + # Each job answers to one flag: docs-lint to `docs`, the rest to `crate`. A skip + # is legitimate only when its flag is false. + if jq -e --arg crate "${{ needs.changes.outputs.crate }}" --arg docs "${{ needs.changes.outputs.docs }}" \ + 'any(to_entries[]; .key != "changes" and .value.result == "skipped" + and ((.key == "docs-lint" and $docs == "true") or (.key != "docs-lint" and $crate == "true")))' \ + /tmp/needs.json >/dev/null; then + echo "::error::a job was skipped although its inputs changed -- that is a dependency failure, not a legitimate skip" + exit 1 + fi + echo "gate: PASS" diff --git a/.gitmodules b/.gitmodules index 508ef42..65b70c7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "packages/pi-gen"] path = packages/pi-gen url = https://github.com/eins78/pi-gen -[submodule "packages/dexd"] - path = packages/dexd - url = https://github.com/eins78/dexd [submodule "packages/branding"] path = packages/branding url = https://github.com/KTE/dex-branding diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c494d7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,222 @@ +# Writing and working rules for the dex repository + +These rules apply to everything a reader outside the project can see: the documentation under `docs/`, `README.md` files, man pages, `--help` and error text, code comments and doc-comments, commit messages that will survive a squash, and the Debian changelog. They were derived from a review of the project's own earlier text and are enforced by `scripts/docs-lint.mjs` where a machine can check them and by review where it cannot. + +Agents: read this file and `docs/glossary.md` before writing or editing any of the above. When a rule and your instinct disagree, the rule wins; when a rule is wrong, change the rule in a pull request rather than working around it. + +## Who the text is for + +- **User documentation** (`docs/guides/`, `packages/dexd/README.md`, man pages, messages the player prints): a venue technician or an artist's helper who can use a terminal and follow a recipe. Assume no knowledge of video codecs, Linux graphics or Rust. Every technical term is either plain English or a glossary entry marked *user*. +- **Developer documentation** (`docs/design/`, code comments, doc-comments): a competent Linux or Rust developer who has never seen this project. Assume general technical knowledge; every video, display or Raspberry Pi specific term is a glossary entry. +- Text that reads only to someone who followed the project's history is a defect, however accurate. + +## The rules + +1. Plan codes stay private. Names such as `F6`, `T7`, `M5`, `C1`, `§5c`, `A3` never appear in public text, identifiers, test names or messages. Name the thing (`the exhibit config`, `the sidecar check`, `the forced-recovery test`). *(lint)* +2. Private documents are not citations. `SPEC.md`, `PLAN.md`, `IMPLEMENTATION-PLAN.md`, the experiment log, `the story`, `the plan`, `the review`, `principle N`, `bug #N` are not citations. State the fact, or link a file under `docs/design/`. *(lint)* +3. Dates are provenance, not structure or justification. No date in a heading; no `as of YYYY-MM-DD` describing current behaviour. History goes to the changelog or the measurement record, with the date there. *(lint)* +4. Emphasis by word order, not typography. `ALL-CAPS` only for acronyms in the glossary, constants and environment variables; bold only for a literal the reader must type or will see on screen. *(lint)* +5. House intensifiers go. `deliberately`, `honest(ly)`, `genuinely`, `load-bearing`, `the whole point / story / trick`, `measured not argued`, `exactly` (unless before a number). If the sentence loses nothing without the word, the word goes. *(lint)* +6. Say what it is, not what it isn't. `Not X, it is Y` is allowed only when the reader plausibly believes X. Otherwise state Y. *(reader; density warned by lint)* +7. Use the field's word; coinages are replaced or defined once. The retired-words table below and `docs/lint-coinages.tsv` list the project's own coinages and what to write instead. Any term that is neither plain English nor in `docs/glossary.md` is a defect. *(lint)* +8. Introduce every referent in the document that uses it. No `the bench Pi`, `the Dell`, `the capture card`, `the box test`, `today's session`. Say what the device or event is the first time. *(lint for codenames; reader for the rest)* +9. Third person, no confession. No `I` / `we` / `us` / `our`, no `we measured`, `the honest answer`. State the fact and its provenance label. *(lint)* +10. Shipped text carries no session, review or revision talk. `three reviews`, `an earlier draft`, `this originally`, `used to be`, `at review time` belong in the changelog or a private log. Shipped text describes current behaviour only. *(lint for the keyword list; reader for the rest)* +11. Doc-comments describe; design docs argue. A doc-comment gives what the item does, its contract, and at most one sentence of why, with a link. Rationale longer than three sentences, rejected alternatives and incident stories move to `docs/design/`. *(reader; length warned by lint)* +12. Every *this / that / it / here* has its noun in the same or the previous sentence. When in doubt, repeat the noun. *(reader)* +13. One qualifier per claim, and a label instead of adverbs. Use *measured / decided / documented / derived / assumed / not tested*, once. *(reader)* +14. Structure by subject; status in words. Headings name what, never when; no emoji as status; no "(later)" splits. *(lint)* +15. A rule, not an aphorism; a mechanism, not a metaphor. If a sentence could be printed on a poster, replace it with the instruction it stands for. *(reader)* + +Numbers carry their unit and conditions ("29.1 fps at 3840×2160, 30 fps, Raspberry Pi 4"). Measured values say so briefly and link `docs/design/measurements.md` for the full conditions rather than repeating them. + +## Tone + +The reference points are a user manual written by the project owner (short paragraphs, one idea each; an unfamiliar notion explained in half a sentence; the reader addressed as "you"; steps as imperatives) and a component reference he considers well written (definition → basic usage → examples → `Important:` / `Note:` callouts → reference list; bullets for parallel behaviours; no history). Match them: + +- **A verb with a clear subject.** dexd, the player, the file, you. Not a nominalised event in the passive: `dexd logs the reason and exits`, not `a refusal is written`; `the exhibit config defines the video, the display mode and the connector`, not `it holds what the installation owns`. +- **Describe the behaviour, never personify or dramatise it.** `If the mode is wrong, dexd does not report it and the display stays black`, not `getting the mode wrong is silent`. `The seek causes the pause`, not `the seek as the cause`. No `holds`, `owns`, `trusts`, `believes`, `honest`, `quietly`, `silently` as characterisation. +- **One idea per paragraph, one to four sentences.** Three or more parallel items become a list; key–value material becomes a table. Sentences average under 25 words. +- **Lead with what to do or what it is; keep the why to one sentence**, or one short `Why` subsection at the end of the page when the reader needs it to decide. Do not stack a reason on a reason. +- **No history in shipped text.** Not what was tried on which day, not the shell loop that proved something, not who found what. A compact `Other options` list — one line per option, name and outcome — is allowed where it helps the reader choose. Longer history belongs in a project log, if one is created later. +- **Length: as short as completeness allows.** A page is finished when every must-cover claim is present once; if it is longer than that, cut. Do not restate conditions or caveats sentence after sentence — state them once, where they matter. +- **User guides: brief but friendly, for a competent adult.** The reader can use a terminal and follow instructions; explain an unfamiliar notion once, in half a sentence, and move on — do not explain what a file, a restart or a command line is. Shape each topic as one sentence of what → the command or the file → one line of what to expect → a `Note:` or `Important:` for the one thing that goes wrong. A code block and one line beat a paragraph. No reassuring filler (`simply`, `just`, `all you need`, `don't worry`), no warning repeated in prose that a callout already carries. Say "you" and use imperatives for steps. +- **Design documents describe the implementation as it is.** The first paragraph says what the page explains and for whom. Then the mechanism (how it works now), the interfaces and requirements it imposes on other parts, and at the end an `Alternatives` table — one line per option: name, outcome, why not. Numbers link to the measurement record. The page does not narrate how the design was arrived at, what was tried on which day, or what the team learned; that history is not told in the public repository. Neutral third person, still direct. +- **Headings name the subject.** A noun phrase of one to four words — `Technical stack`, `Frame pipeline`, `Configuration file`, `Exit codes` — never a sentence, a contrast, an intensifier or a tease (`The stack, and where each layer stops`; `How a frame actually travels`; `The extension is honoured, not sniffed`). +- **State it once, without defending it.** Do not answer an objection the reader has not raised, and do not grade the project's own honesty or restraint (`the honest count is 228 shared objects, not zero`). State what it is, once, and link the thing it builds on. + +Bad → good, from the first drafts: + +| Draft | Rewrite | +|---|---| +| the reason is measured | measured on a Raspberry Pi 4 (see the measurement record) | +| a refusal is written | dexd logs the reason and exits | +| Getting the mode wrong is silent | If the mode is wrong, dexd does not report it | +| It holds what the installation owns: the mode, the force flag and the connector | It defines the display mode, the forced mode and the connector | +| the seek as the cause | the seek causes the pause | +| `The stack, and where each layer stops` (heading) | Technical stack | +| `How a frame actually travels` (heading) | Frame pipeline | +| `The extension is honoured, not sniffed` (heading) | Format by file extension | +| `What the video must be` (heading) | Requirements on the video | +| `Gapless HEVC looper for the Raspberry Pi. One process, no shell, and a dependency set small enough to read — all of it declared (SPEC §5c). It links libmpv, so the honest count is 228 shared objects, not zero.` | Gapless HEVC video looper for Raspberry Pi, based on [libmpv](https://mpv.io/). | + +### Comments and configuration files + +A comment is a note for the next reader or editor: what this is, why it is this way, what to check before changing it. It is not a report of how the project arrived here. The same rules as above, plus: + +- **State what is used and why, in one sentence.** `# Debian's rustc and cargo, because the package is built for Debian and must build with the compiler the devices have.` Not a shouted header, a paragraph of history and a reference to "the paragraph above". +- No history. `originally`, `this was`, `used to`, `we found`, `at review time`, `today` — cut, or move the fact to the design docs if it still matters. `# This was originally rustup stable, which was inconsistent…` says nothing a future editor needs. +- **No dramatisation, no verdicts on the code's own virtue.** `the cost is real and deliberate`, `what it buys`, `only worth anything if`, `not a routine bump`, `DECLARED, not avoided`, `CHECKED rather than merely written down` — delete the judgement, keep the instruction or the fact. +- **Give the editor an action.** `# Only raise this after confirming the target devices can still build the package.` Not `# Raising this floor is a decision about whether devices can still build their own software, not a routine bump.` +- **Comments in configuration files are short.** One or two lines above the setting they explain; a paragraph only for a rule that is not obvious from the setting itself. A configuration file that reads like an essay is a design document in the wrong place. + +Bad → good, from the CI workflow and Cargo.toml: + +| Draft | Rewrite | +|---|---| +| `That guarantee is only worth anything if the build environment IS the target environment.` | That can only be guaranteed if the build environment is the target environment. | +| `TOOLCHAIN: DEBIAN'S RUSTC, NOT RUSTUP — This was originally rustup stable, which was inconsistent with the paragraph above… What it buys: … The cost is real and deliberate` | Debian's rustc and cargo, not rustup: the package is built for Debian, so it is built with the compiler the devices have. Dependencies that need a newer compiler cannot be adopted; that is intended. | +| `Raising this floor is a decision about whether devices can still build their own software, not a routine bump.` | Only raise this after confirming the target devices can still build the package. | +| `Dependencies are DECLARED, not avoided — SPEC §5c. … a rule that forbade four small cargo crates while linking that was bookkeeping, not restraint.` | Dependencies: keep the set small enough to read; no procedural macros. Each entry below says what it is for. | +| `THE POINT OF THE PACKAGE. "$auto" runs dpkg-shlibdeps over the built binary, so Depends is DERIVED … and cannot drift from reality the way a hand-written list would. Never replace this with a literal list.` | `$auto` derives Depends from the libraries the binary links, so a libmpv version mismatch fails at install time. Keep it; add explicit floors below it for behaviour that no symbol expresses. | + +## Vocabulary + +`docs/glossary.md` is the only list of technical terms the documentation may use without explaining them. Its entries were approved one by one by the project owner. Rules for the file: + +- **Agents propose, never approve.** To add or change an entry, open a pull request that touches `docs/glossary.md`; `.github/CODEOWNERS` routes it to the owner. Do not merge glossary changes yourself, and do not paraphrase an existing definition. +- A user-tier definition must be understandable with no other entry. A developer-tier definition may reference other entries with "(see …)". +- The retired words below are never used in public text, whatever the tier. `scripts/docs-lint.mjs` reports the ones a machine can catch. + +### Names that were decided + +| Use | Not | +|---|---| +| `dexd` — the package, the binary, the service; the player | `dex-loop`, `dex_loop`, "the looper" | +| `dex-sidecar write` / `dex-sidecar check` | `make-sidecar.sh`, `sidecar-check` | +| `dex-exhibit-apply`, `dex-wait-hdmi` | — | +| **exhibit config** — the file `/etc/dex/exhibit.json` or `.yaml` | "the exhibit" on its own | +| **asset** / **video asset** — the video file; the **artwork** is the whole installation it plays in | "the artwork" for the file | +| **forced display mode** in prose; `kms_force` only as the literal config key | "kms force", "KMS forcing" | +| **system log** in prose; `journalctl -u dexd` in commands | "the journal" | +| **dex card** — the SD card that makes a Raspberry Pi a player | `player card` | +| **video container** | "container" alone | +| **loop point**; **gapless** / **seamless** (property); **a held frame**, **a freeze**, **the picture is stuck** (defect) | `seam`, `the wrap`, `hold`, `wrap point` | +| **long-running test**, **24-hour test** | `soak` | +| **test video** (an encoded test file); **test card** (the synthetic picture it is made from) | `bench asset` | +| **test rig** — one test setup; the **bench** — the development workstation and its hardware | "bench" for a setup | +| `--test-rig-no-sidecar`, `--test-rig-hang-after-secs`, `--test-rig-force-recovery-after-secs`; `(test rig only)` | `--bench-*`, `BENCH ONLY`, `wedge` | +| **unresponsive**, **hangs**, **is hanging** | `wedged`, `hung` | +| **supervisor thread** | `event thread` | +| `loops=` in the heartbeat; **loop count** or **loop iterations** in prose | `wraps=`, `wrap count` | +| **check** — the sidecar check, the asset check, the cmdline check | `gate` | +| **prepare the video** (user text and messages); *ingest* only in developer text | `re-ingest` | +| **refuses to start rather than guess** (user text); *fail-closed* only in developer text | `fail-closed in a guide` | +| **frame-duration histogram** | `dwell histogram` | +| **written into**, **stored in**, **saved copy of the EDID**, **build-id file** | `baked`, `baked-in`, `stamped`, `stamp file` | +| **in-place recovery**, **process restart by systemd**, **reboot escalation (planned)** | `tier 0 / 1 / 2 / 3` | +| **the pass criteria** | `the bar`, `the pass bar` | +| dexOS (the brand); `dex-os` (the repository) | `Dexbian` | + + +### Retired words — the full table + +| Retired | Write instead | +|---|---| +| `soak` / `soak test` / `24 h soak` / `soak run` / `soak harness` / `thermal soak` | long-running test / 24-hour test / long-term test (name the duration where it matters); 'the long-running-test harness' | +| `seam` / `the seam` / `seamless-loop as noun` / `'no seam'` / `'a seam'` | place: 'the loop point'; property: 'gapless' or 'seamless'; defect: 'a visible pause / a held frame / a stutter at the loop point' | +| `the wrap` / `wrap point` / `at the wrap` / `wrap-join` / `wrap transition` / `wr` | 'the loop point' (place); 'one loop' / 'one repeat' (the pass); 'loop count' (the counter); 'loop-position arithmetic' (the code) | +| `hold` / `holds` / `hold at the wrap` / `held (as noun)` | 'a freeze' / 'the picture is stuck at the loop point' / 'the frame stays on screen for N ms' — describe the defect plainly | +| `bench asset` / `bench-ready asset` / `the card (meaning the encoded video)` | 'test video' / 'the reference test video used for measurements' (a test video made from a test card) | +| `gaplessness premise` / `loop-ability` | 'the requirement that the loop is gapless' / 'whether a file can loop gaplessly' | +| `tier 0` / `tier-0` / `tier 1` / `tier 2` / `tier 3` | 'in-place recovery' (0), 'process restart by systemd' (1), 'reboot escalation (planned)' (2), 'hardware watchdog (planned)' (3) | +| `fail closed (user tier)` / `fail-closed contract` / `fail-silent` | user tier: 'refuses to start rather than guess'; developer tier: 'fail-closed' is a glossary term | +| `live-fire` / `live-fire probe` / `live-fire test` | 'against a real mpv instance' / 'on real hardware' / 'the forced-recovery test' | +| `wedged` / `wedge` / `core-wedge` / `display-wedged` / `'the wedge check'` | 'unresponsive' / 'hangs' / 'is hanging' / 'stopped responding while the process stays alive' — never `hung`; the flag becomes --test-rig-hang-after-se | +| `pinned (a behaviour is 'pinned' by a test)` | 'locked in by a test' / 'a test enforces' | +| `the loser` / `delete the loser` | 'the unwanted config file' / 'delete the one you do not mean' | +| `drift generator` | 'would make the boot config and the player's config diverge' | +| `black-wall time` | 'the worst-case time the screen can stay dark' | +| `spins hot` | 'busy-loops, using a full CPU core' | +| `belt-and-braces` | 'a fallback' / 'a second safeguard' | +| `the honest count` / `'honest' as an intensifier` | state the number: 'the binary links 228 shared objects' | +| `green CI` | 'CI passes' / 'a passing CI run' | +| `trap point` | 'the point inside mpv where the wait would unblock' | +| `event-shape` | 'the sequence of events' / 'this event' | +| `the classic monorepo trap` | 'a required check that can silently never run, blocking every merge' | +| `the crux` | 'the central tension: dexOS is buster, the player needs trixie' | +| `the box` / `the box test` / `'shares the box'` | 'the device' / 'the sealed-case thermal test' | +| `the rig` / `capture rig` / `'Bench = …'` | 'the measurement setup (a Pi 4, an HDMI capture device and the analysis scripts)' | +| `the wrong-panel case` | 'a resolution the connected display cannot show' | +| `venue truth, not asset truth` | 'the display mode belongs to the installation, not to the video file' | +| `the mains switch is the shutdown path` | 'there is no graceful shutdown; power is simply cut, and the player is built to survive that' | +| `field journal` / `field failure` / `in the field` / `on site` / `gallery devic` | 'the log' / 'a failure at the venue' / 'at the venue' / 'deployed players' | +| `deploy path` / `bench escape hatch` | 'normal startup (sidecar required)' / 'the test-rig-only override (`--test-rig-no-sidecar --fps`)' | +| `the binding` / `asset+fps binding` / `F3 gate` / `sidecar gate` / `NAL gate` / `` | 'the sidecar's checksum match' / 'the sidecar check' / 'the asset check' / 'the cmdline check' — 'check' in prose; 'gate' allowed as alias (? — needs | +| `THE EXTENSION DECIDES THE PARSER (all caps)` / `BENCH ONLY` / `ARMED (shou` | sentence case: 'the file extension selects the parser'; the literal warning line stays as shipped | +| `escalation ladder` / `'escalate per the fixed ladder'` | 'the pre-committed fallback order (pivid, then GStreamer, then a custom player)' | +| `annulus` / `fps honesty` / `matched wrap` / `'the wrap is matched by constru` | 'ring-shaped region' / 'how far a detected frame rate can be trusted' / plain description | +| `cleanroom extraction` / `cleanroom` | 'rewritten from scratch for publication' | +| `buster ceiling` | 'the buster limitation' / describe: 'gapless hardware playback only on buster (32-bit), so no upgrades and no Pi 5' | +| `the rotation trap` | 'sideways video from phone footage: the container's rotation flag is lost on extraction' (see elementary stream) | +| `(nogit)` / `+dirty as prose` | 'an unidentified build' / 'a build from uncommitted changes' — the literal version-string markers stay | +| `hello_video positive control` / `dexOS card` / `'the dexOS positive contro` | 'the known-good reference (the legacy hello_video player on its own test video)' | +| `mp_dispatch_lock` / `run_locked` / `mp_cond_wait` / `mp_dispatch_queue_proce` | describe the behaviour ('a synchronous property read waits with no timeout for mpv's core thread'); cite the mpv source location in a footnote if prov | +| `Rust identifiers used as prose nouns (HealthMonitor, ObservedCounter,` | in docs: describe the behaviour and name the module once ('the health policy in health.rs'); identifiers belong in code and API docs, not in guides | +| `supervisor thread (health.rs) vs event thread (heartbeat.rs, watchdog.` | 'supervisor thread' everywhere (one thread) | +| `gst1223` / `+rpt2 check` / `'the rpt2 criterion' as bare labels` | 'a GStreamer 1.22 attempt' / 'whether Raspberry Pi's patched ffmpeg build (+rpt2) is required on the Pi 5 — unresolved' | +| `USV` | 'battery backup (`UPS`)' | +| `starved feed` / `'signature of a starved feed'` | 'the data source not keeping up (frames held at random points, not at the loop point)' | +| `the linger bug` | 'the tmux session died with the last SSH login (systemd user session not lingering)' — an operations note for the private record, not dexd | +| `kiosk (flags` / `mode)` / `argv` / `'the working argv'` | 'fullscreen with no on-screen controls' / 'the mpv command line' | +| `baked` / `baked EDID` / `baked-in` / `stamped` / `stamp file` / `build stamp` | 'written into' / 'stored in' / 'saved copy of the EDID' / 'build-id file' — the words `baked` and `stamped` appear nowhere | +| `hung` | 'hangs' / 'is hanging' / 'unresponsive' — never `hung` | +| `--bench-no-sidecar` / `--bench-wedge-after-secs` / `--force-recovery-after` | `--test-rig-no-sidecar` / `--test-rig-hang-after-secs` / `--test-rig-force-recovery-after-secs` / `(test rig only)` | +| `wraps=` / `WRAP_COUNT` / `wrap count` | loops= (heartbeat field, code rename) / 'loop count' / 'loop iterations' in prose | +| `event thread` | 'supervisor thread' | +| `gate (as the noun for a startup refusal)` / `F3 gate` / `cmdline gate` / `NA` | 'check' — the sidecar check, the asset check, the cmdline check | +| `fail-closed` / `fail closed (user tier)` | 'refuses to start rather than guess' at user tier; developer tier keeps the glossary entry fail-closed | +| `kms_force (in prose)` | 'forced display mode' in prose; `kms_force` only as the literal config key | +| `journal` / `the journal (in prose)` | 'system log' in prose; `journalctl` in commands | +| `dwell` / `dwell histogram` / `dwell counts` | 'frame duration' / 'frame-duration histogram' | +| `player card` | 'dex card' | +| `container (alone)` | 'video container' | +| `re-ingest the asset (shipped message)` | 'prepare the video again with dex-sidecar write' | +| `ingest (user tier)` | 'prepare the video' / 'preparing a video'; developer tier may say ingest | + +### Names of people, places and things + +- No artist names, artwork titles, venues, exhibition names, SD-card ids, hostnames of development machines, or the owner's name in public text. `The project decided` replaces a person's name. +- A forum handle may appear in prose when the person's real name is unknown or the account is pseudonymous — always marked and explained: `*Foo*, a Raspberry Pi engineer on the official forums, …`. Otherwise `a Raspberry Pi engineer on the official forums`, with the link as the citation. +- The measurement instrument may be named once, as the instrument's identity, in `docs/design/measurements.md` (`an Elgato Cam Link 4K HDMI capture device`); one display model may serve as a worked example of a forced display mode. Everywhere else: `the capture device`, `a 2560×1440 monitor`. + +## Provenance labels + +Every measured number, decision and assumption in the documentation traces to a source. In text use one label, once: *measured* (say on what: "measured on a Raspberry Pi 4"), *decided*, *documented* (name the manual or spec), *derived*, *assumed*, *not tested*. The measurement record (`docs/design/measurements.md`) holds the conditions; other documents link it. + +## The mechanical gate + +``` +node scripts/docs-lint.mjs # default paths: packages/dexd, docs, .github/workflows/dexd.yml, AGENTS.md, README.md +node scripts/docs-lint.mjs docs/guides/prepare-video.md +``` + +Errors fail the run; warnings are printed. It reads `docs/glossary.md` (the acronym allow-set), `docs/lint-allow.txt` (per-token or per-path exceptions — every entry needs a reason after `#`, or the tool refuses to start) and `docs/lint-coinages.tsv` (retired words). It runs in CI on `docs/`, `AGENTS.md` and `README.md` files; the crate's comments join the gate when their rewrite lands. Fix an error by rewording; add an allowlist entry only for a true false positive, with the reason. + +## Where things go + +| Content | Place | +|---|---| +| How to build a dex card, prepare a video, configure the exhibit, run and troubleshoot | `docs/guides/` (user tier) | +| Reference: config keys, options, exit codes, every refusal message and its fix | `docs/guides/reference.md` | +| Why the player is built the way it is; how it fails and recovers; packaging and CI; measurements | `docs/design/` (developer tier) | +| The one-page front door | `packages/dexd/README.md` | +| Terms | `docs/glossary.md` | +| What changed between releases | `packages/dexd/deploy/changelog` (Debian format) | +| Rationale moved out of a code comment | the `docs/design/` page the comment links | + +## Commits and code + +- Commit subjects: `E:` for code and packaging, `D:` for documentation, imperative, ≤ 72 characters; the *why* in the body. No AI attribution lines. +- No behaviour change rides along with a wording change. Renames that the vocabulary requires (a flag, a heartbeat field, an identifier) are their own commit with tests updated. +- Test names are prose: `an_fps_that_contradicts_the_stream_is_refused`, not `f6_bad_fps`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index ba46fee..eff7f0d 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,80 @@ -# `dex` project +# dex -packages: +dexd plays the video of an installation on a Raspberry Pi, unattended, for weeks. This repository +holds dexd and the parts around it. -* [dex OS](./packages/dex-os/README.md) -* [example content](./packages/example-content/README.md) -* [branding](./packages/branding/README.md) +If you are setting up a player, start with [What dexd is](docs/guides/what-dexd-is.md). -## getting started +## Packages -Build a dex player by flashing the image to an SD card, -using the official [Raspberry Pi Imager](https://www.raspberrypi.org/software/). +| Path | What it is | +|---|---| +| [`packages/dexd`](packages/dexd/README.md) | The player: gapless 4K HEVC looper for Raspberry Pi 4, shipped as a Debian package. Documentation in `docs/guides/` and `docs/design/`. | +| `packages/dex-os` | dexOS, dex's own Raspberry Pi OS image; it currently plays videos with pi_video_looper, not dexd. | +| `packages/example-content` | The test cards: short videos with a frame counter, colour bars and a checkerboard border. They show whether a player displays the picture correctly and loops gaplessly. | +| `packages/branding` | The project's colour palette and design sketches. | +| `packages/website` | The project site, . | +| `packages/pi-gen` | Raspberry Pi's official tool for building OS images in stages; dexOS is a pi-gen build. | +| `packages/pi_video_looper` | Adafruit's Python video-looping framework. | -Then boot the Raspberry Pi with the SD card. -It it worked, it will show a 2-second demo video loop. - -### advanced - -For customizing the player/operating system, -ssh access needs to be enabled. - -This can be done using the "customization" feature of the Raspberry Pi Imager, -choosing "Enable SSH" in the "Advanced Options". It is recommended to use key-based authentication. -The user name in the image is `dex` should not be changed, the default password is also `dex` and should be changed if SSH login is enabled and password authentication is used. - -Then, after booting the Raspberry Pi, ssh into it: - -```sh -ssh dex@dexpi # or another hostname if you changed it in the customization -``` - -## development - -### creating patches - -Upstream repos like `pi-gen` are not forked directly, -rather a series of patches is maintained. -This makes the list of changes we make self-documenting, -and over time should be easier than maintaining a regular fork using `git`. - -Good tutorials on using `quilt`: - -* -* - -```sh -quilt new "99-name-of-my-patch" -quilt add ./packages/some-upstream-code/some-file -# edit ./packages/some-upstream-code/some-file -quilt refresh # patchfile is added to ./patches and patch name is added to ./patches/series -quilt rename "99-better-name-of-my-patch" -``` - -editing existing patches: +The player, the OS image and the website live in this repository, so one commit changes the player, +its packaging and the OS image together. The other four — `branding`, `example-content`, `pi-gen` +and `pi_video_looper` — are git submodules, pointers to separate repositories. Clone the +repository, then fill the submodules in: ```sh -PATCH_NAME="project/99-name-of-my-patch" -quilt add -P "$PATCH_NAME" ./packages/some-upstream-code/some-file -# edit ./packages/some-upstream-code/some-file -quilt refresh "$PATCH_NAME" # patchfile is updated in ./patches +git clone https://github.com/KTE/dex.git +cd dex +git submodule update --init ``` -## housekeeping - -### update pi-gen repo - -```sh -cd packages/pi-gen -git remote add upstream https://github.com/RPi-Distro/pi-gen -git fetch upstream -git push --mirror origin -``` +## Guides + +Read these to build a player and keep it running. They assume you can use a terminal, and nothing +about video or Linux. + +1. [What dexd is](docs/guides/what-dexd-is.md) — what the player does and what it needs. +2. [Build a dex card](docs/guides/build-player-card.md) — from a blank SD card to a booted player. +3. [Prepare your video](docs/guides/prepare-video.md) — turning the video you exported into the `.265` file and sidecar dexd accepts. +4. [Configure the exhibit](docs/guides/configure-exhibit.md) — the one file that names the video, the display mode and the connector. +5. [Run, check, troubleshoot](docs/guides/run-check-troubleshoot.md) — starting the player, reading the system log, and going from a symptom to a fix. +6. [Reference](docs/guides/reference.md) — config keys, sidecar keys, exit codes, file paths and every refusal message with its fix. + +The package installs man pages for `dexd`, `dex-exhibit-apply` and `dex-wait-hdmi`. Build +`dex-sidecar` from source on the computer where you prepare the video; from the repository root, +read its page with `man ./packages/dexd/deploy/man/dex-sidecar.1`. + +## Design documents + +Read these to change the player. They assume a Linux or Rust developer who has not seen the +project. + +| Page | Subject | +|---|---| +| [Architecture](docs/design/architecture.md) | The layers from Rust down to the display. | +| [The endless stream](docs/design/endless-stream.md) | How playback repeats without reaching the end of the file. | +| [Startup checks](docs/design/startup-checks.md) | What dexd verifies before it plays, and the exit codes. | +| [Failure handling](docs/design/failure-handling.md) | How a running player detects that it stopped showing pictures, and what it does then. | +| [The systemd unit](docs/design/service-unit.md) | Every setting in `dexd.service` and the scripts around it. | +| [Asset binding](docs/design/sidecar.md) | The sidecar file that records the video's frame rate and checksum, and the check that reads it. | +| [Exhibit config](docs/design/exhibit-config.md) | The per-installation file: grammar, refusals and the kernel command line `dex-exhibit-apply` derives from it. | +| [Packaging](docs/design/packaging.md) | What the `.deb` contains, and where it installs. | +| [Continuous integration](docs/design/ci.md) | What the workflow builds and asserts. | +| [Building and testing dexd](docs/design/development.md) | Build commands, test layers and their safety rules. | +| [Raspberry Pi media capability](docs/design/pi-capability.md) | What each Raspberry Pi generation can decode and display. | +| [Roadmap](docs/design/roadmap.md) | What is decided but not built, and what is out of scope. | +| [Measurement record](docs/design/measurements.md) | Every number the documentation relies on, and how it was established. | + +## Vocabulary + +[docs/glossary.md](docs/glossary.md) is the term list for this repository. Entries marked *user* +are the technical words the guides use without explaining them; entries marked *developer* are used +only in the pages under `docs/design/`. A word in neither list is plain English or is explained +where it is used. The writing rules are in [AGENTS.md](AGENTS.md). + +## Licence + +dexd's source, packaging and documentation are under the MIT-0 licence. The project's content — +test cards, video masters, branding — is under CC0-1.0. Both allow any use, with no attribution and no +conditions. Because the installed package links Debian's mpv library, the binary you install ships +under GPL-3+ — see [Packaging](docs/design/packaging.md). diff --git a/docs/design/architecture.md b/docs/design/architecture.md new file mode 100644 index 0000000..08e958d --- /dev/null +++ b/docs/design/architecture.md @@ -0,0 +1,215 @@ +# Architecture + +dexd plays one HEVC video in an endless gapless loop on a Raspberry Pi 4. This page is for a developer reading the source for the first time: it describes the layers between the Rust code and the HDMI output, the route a decoded frame takes through them, and how the crate is divided. The division lets the decision logic be tested on a machine with no Raspberry Pi and no libmpv. + +Terms are defined in [the glossary](../glossary.md). Measured numbers and their conditions are in the [measurement record](measurements.md); the looping mechanism itself is in [Why an endless stream](endless-stream.md). + +## Technical stack + +dexd is one process that links libmpv. It registers a stream protocol with the library, sets the playback options, issues the first `loadfile` and then services mpv's event queue for as long as the player runs. Everything below libmpv is FFmpeg, the kernel and the chip. + +``` +dexd loop:// stream, mpv option set, startup checks, + END_FILE as fatal, mpv log capture +libmpv 0.40 presentation and timing, DRM output, KMS plane import +FFmpeg Annex-B demux, V4L2-request HEVC decode +kernel rpi-hevc-dec -> dma-buf -> vc4 DRM/KMS -> plane, CRTC, connector +BCM2711 HEVC decode block -> CMA -> HVS -> PixelValve -> HDMI PHY +``` + +- dexd registers the `loop://` stream whose read callback never returns 0, so mpv never sees an end of file. It sets the option set below, runs the [startup checks](startup-checks.md) before mpv is created, treats an unexpected `END_FILE` as fatal, and forwards mpv's log messages to standard error. +- libmpv 0.40 does presentation and timing: scheduling locked to the display's refresh through `video-sync=display-resample`, `vo=gpu` with `gpu-context=drm` for modesetting, atomic commits and page flips, and `gpu-hwdec-interop=drmprime-overlay` to hand each decoded frame to a KMS plane. +- FFmpeg, inside mpv, demuxes the raw Annex-B stream and decodes it through the V4L2 request API. An elementary stream carries no timestamps, so the frame rate comes from the sidecar as `container-fps-override` (see [Asset binding](sidecar.md)). +- The kernel connects the stateless HEVC decoder (`rpi-hevc-dec`, `/dev/video19`) to DRM/KMS through the `vc4` driver by dma-buf, landing on a plane, a CRTC and the configured connector. +- BCM2711 decodes into SAND-tiled NV12 in CMA. The HVS reads that layout without conversion and drives PixelValve to the HDMI PHY at 297 MHz TMDS for 3840×2160, 30 fps. + +FFmpeg does the decoding, whichever player is used; mpv contributes presentation, timing and plane management. Debian trixie's ffmpeg already carries the Raspberry Pi HEVC patches, so hardware decode comes from system packages with no vendoring, and mpv is driven from a command line. + +## Frame pipeline + +A decoded frame does not travel up the stack. The HEVC block writes it once into CMA, and the HVS scans it out of that same memory. Between those two events only a dma-buf file descriptor moves, which libmpv hands to KMS as a framebuffer. Compressed bytes and control flow downward; pixels move at the bottom, in hardware. + +``` +software + dexd ──► libmpv ──► FFmpeg demux ──► V4L2 decode request + │ + libmpv ◄╌╌╌╌╌ dma-buf file descriptor ╌╌╌╌╌╌┘ + │ + └──► KMS atomic commit: the descriptor becomes a framebuffer on a plane +───────────────────────────────────────────────────────────────────────────── +hardware + HEVC decode block ══► CMA ══► HVS ══► PixelValve ══► HDMI + + ──► compressed bytes and control ╌╌► a descriptor, no pixels ══► pixels +``` + +## Zero-copy path + +The Pi's HEVC decoder emits NV12 only in Broadcom's 128-byte-column SAND tiling; the driver refuses a request for linear NV12. The Pi 4 display plane scans SAND out natively, so a frame reaches the screen untouched — but only when it goes straight onto a KMS plane. Every other output arrangement converts the tiling first, and the conversion costs most of the frame rate. + +mpv offers two ways of handing a DRM PRIME frame to the display — its interops — and only one of them avoids that conversion. + +| Output arrangement | Result at 3840×2160, 30 fps | +|---|---| +| `gpu-hwdec-interop=drmprime-overlay` (frame onto a KMS plane) | realtime, 30 fps, no dropped frames | +| `drmprime` (mpv's import into an OpenGL texture) | about 5 fps | +| `hwdec=drm-copy` (detile on the CPU, not an interop) | 14.3 fps | + +The cost is source pixels per second, the one quantity no encoder setting changes. Measured on a Raspberry Pi 4 running Debian trixie; conditions in the [measurement record](measurements.md). + +| Change | Effect on playback | +|---|---| +| output resolution 3840×2160 → 2560×1440 | none | +| source resolution 3840×2160 → 1920×1080 | the rate doubled | +| bitrate 39.3 → 3.1 Mbps, a factor of 12.5, at the same resolution, frame rate and GOP | 14.3 → 15.2 fps, a gain of 6 % | + +## Plane assignment + +dexd puts the video on the primary plane and mpv's own drawing surface on the overlay plane, through `drm-drmprime-video-plane=primary` and `drm-draw-plane=overlay`. This is the reverse of mpv's defaults, and it keeps the 4K video off the 3D render path. + +**Note:** mpv sets the plane stacking property on the video plane only, so whether the video stays visible under mpv's surface depends on the `vc4` driver's default plane ordering. The project verified the plane ordering on a Raspberry Pi 4; re-verify it after a kernel upgrade or on another DRM driver. + +## Option set + +dexd passes this option set to libmpv before `mpv_initialize`. + +| Option | Value | Effect | +|---|---|---| +| `vo` | `gpu` | mpv's GPU video output | +| `hwdec` | `drm` | hardware decode, frames as DRM PRIME handles | +| `gpu-context` | `drm` | draw through DRM directly, with no compositor | +| `gpu-api` | `opengl` | the graphics API for mpv's own surface | +| `gpu-hwdec-interop` | `drmprime-overlay` | the frame goes onto a KMS plane | +| `drm-draw-plane` | `overlay` | mpv's surface on the overlay plane | +| `drm-drmprime-video-plane` | `primary` | video on the primary plane | +| `video-sync` | `display-resample` | schedule frames against the display's refresh | +| `hwdec-software-fallback` | `no` | a lost hardware path becomes an error | +| `fullscreen` | `yes` | fill the screen | +| `osc` | `no` | no on-screen controls | +| `input-default-bindings` | `no` | no key bindings | +| `terminal` | `no` | no terminal output; log messages arrive as events | +| `correct-pts` | `no` | mpv generates timestamps, since the stream has none | +| `demuxer-max-bytes` | `64MiB` | a ceiling on the read-ahead cache | +| `demuxer-readahead-secs` | `1.0` | the effective prefetch depth: one second of decoded-ahead insurance across the loop point | +| `container-fps-override` | the resolved frame rate | the frame rate the sidecar supplies | +| `drm-connector` | the resolved connector | always passed, so the output is never left to mpv's own pick | +| `drm-mode` | the resolved display mode | omitted when the display mode is `auto`, which is mpv's own default of `drm-mode=preferred` | + +The last three values are resolved before mpv is created: the frame rate from the sidecar, the connector and the display mode from the [exhibit config](exhibit-config.md). `--no-defaults` drops the sixteen built-in options; it does not affect the last three. + +The automated tests replace the option set with `--no-defaults --opt vo=null --opt vid=no --opt aid=no`, so mpv runs with no display and no hardware decode (see [Building and testing dexd](development.md)). + +Without `hwdec-software-fallback=no`, a decoder that cannot reach the hardware path falls back to software without reporting it and plays 3840×2160, 30 fps at about 14 fps. With the option set, the same condition arrives as an `END_FILE`: dexd logs it and exits, and systemd restarts the player. + +`demuxer-readahead-secs` is the bound that governs memory, because mpv runs its aggressive cache only for streams flagged as network and a custom stream is not one; `demuxer-max-bytes` is a second safeguard. + +A rejected option exits 2: the same invocation fails identically on the next start, so a restart cannot help. + +## Display ownership + +DRM grants the right to drive a display to one process at a time. dexd's systemd unit conflicts `getty@tty1.service` away and orders itself after `multi-user.target`, which prevents “device busy” instead of recovering from it. The kernel releases DRM master when the process exits, so the next start acquires it cleanly. The settings are covered line by line in [The systemd unit](service-unit.md). + +For the same reason the dex card is built on Raspberry Pi OS Lite. The desktop image ships a Wayland compositor, which would sit between the decoder and scanout. + +**Note:** the single-master rule is not an absolute bar to a display server. A Raspberry Pi forum thread describes VLC, started fullscreen from X, borrowing X's planes through DRM leases and still scanning out with no copy (see [Raspberry Pi media capability](pi-capability.md)). Whether mpv can do the same is not tested by this project. + +## Language and bindings + +dexd is written in Rust. The player runs unattended for weeks at 3840×2160, 30 fps with a hard per-frame budget. Two failure classes common in C are costly under that load: a slow leak that appears only after days of uptime, and a use-after-free in buffer handling that shows as corrupt frames rather than a clean crash. Rust's ownership rules make the use-after-free a compile error, and its allocation discipline removes the ad-hoc buffer lifetimes that produce the slow leak. + +libmpv's `stream_cb` is a C API, so the Rust side of it is one `copy_nonoverlapping` against a per-frame deadline — no interpreter and no allocation between the bytes and mpv. + +Only the libmpv entry points this program calls are declared, hand-transcribed from `mpv/client.h` and `mpv/stream_cb.h` of mpv 0.40.0. The surface is small and stable, and a build-time code generator would be a heavier dependency than the declarations it replaces on a device that builds offline. A transcription can rot, so `tests/ffi_constants.rs` asserts every event id and the one error code against the linked library through `mpv_event_name()` and `mpv_error_string()`, matching the strings character for character; that also catches a future renumbering. + +Five further rules constrain the FFI declarations and callbacks in `src/main.rs`: + +- `mpv_get_property_string`, `mpv_get_property` and `mpv_free` are not declared at all. A synchronous property read waits without a timeout for mpv's core thread to reach its dispatch loop, and a core stuck in a display call never reaches it. +- Only the first two fields of `mpv_event_end_file` are read; the trailing playlist fields are irrelevant to a single-file appliance, and reading a prefix of a `#[repr(C)]` struct is well defined. +- A property-change payload is a tagged union. The handler checks both the subscription tag and the format tag before it touches the data; the handler ignores a mismatch, and the next health check sees no new sample. +- Both the development and release profiles set `panic = "abort"`: unwinding across the FFI boundary, or out of `main` while mpv threads are live, is undefined behaviour. +- The binary sets `#![deny(unsafe_op_in_unsafe_fn)]`, so each unsafe operation needs its own `unsafe` block and its own safety comment, including inside an `unsafe fn`. + +One portability detail is easy to undo by accident: the read callback converts its buffer with `.cast::()`, because `c_char` is signed on some hosts, macOS on aarch64 among them, and unsigned on the Raspberry Pi. A bare `buf` compiles only on the Raspberry Pi. + +## Crate layout + +The binary is a thin unsafe shell over the library. FFI declarations, the callbacks and the event loop live in `src/main.rs`; everything decidable lives in `src/lib.rs` and its modules, which carry `#![forbid(unsafe_code)]`. A forbid cannot be lifted locally, not even in a test module. The library builds and tests with `cargo test --lib` on any machine, with no libmpv and no display. + +The library re-exports nine modules: `chunk` (loop-position arithmetic), `exhibit` (config grammar, display resolution, cmdline reconciliation, sysfs mode lists), `ffi_consts`, `health`, `heartbeat`, `nal`, `sha256`, `sidecar` and `watchdog`. + +Policy lives in the library, wiring in the binary. + +- `read_fn` performs the copy; `chunk::next_chunk` decides the offsets and enforces three rules: never answer with zero bytes, return the position to byte 0 as soon as a copy reaches the end of the payload, and saturate an oversized request. +- `health` decides whether a sample means progress, an in-place recovery or an exit; `main.rs` only feeds it position samples on a fixed cadence. +- `watchdog` builds the address, performs the handshake and wraps the non-blocking send, unit-tested against real sockets; the socket's lifetime and the call site stay in `main.rs`. +- `exhibit` holds the display, cmdline and sysfs functions, so they can be tested without a Raspberry Pi. + +## Programs + +The package installs two binaries, and the crate builds a third for use on a workstation. + +| Program | Runs on | Privileges | Purpose | +|---|---|---|---| +| `dexd` | the player | unprivileged `dex` user | plays the video, see dexd(1) | +| `dex-exhibit-apply` | the player | root | writes the forced display mode into `cmdline.txt`, see dex-exhibit-apply(1) | +| `dex-sidecar` | a workstation | none | writes and checks a video's sidecar | + +`dex-exhibit-apply` ships because an operator runs it on the device, as root, after editing the exhibit config; the player is sandboxed and writes no boot config. It is another thin privileged shell: the grammar and the reconciliation logic live in the library, testable with no root and no real boot partition. Its root test calls `geteuid` through a hand-written declaration instead of a dependency for one system call. + +`dex-sidecar` is not installed: a video is prepared on a workstation and copied to the player (see [Prepare your video](../guides/prepare-video.md)). The package also ships `dex-wait-hdmi`, a shell script the unit runs before the player, see dex-wait-hdmi(1). + +## Stream callback and memory + +dexd reads the asset once at startup and leaks it as a `&'static [u8]`. That takes the filesystem off the hot path: no re-open, no page-cache dependency, no read stalling a frame at the loop point. It is never freed, because it must outlive every mpv thread. The files are small: a three-second video is 1.3 MB at 1920×1080 and 14.8 MB at 3840×2160. + +The protocol's `user_data` is a leaked `Box` around the payload slice, not a pointer into `main`: mpv may dereference it from its own threads until `mpv_terminate_destroy` returns. A stack slot would be undefined behaviour the moment anything unwound. The `Box` is sixteen bytes and is never freed. + +The per-stream cookie is a separate `Box` that `open_fn` allocates and `close_fn` reclaims; mpv calls `close_fn` once. + +Exclusive access in `read_fn` comes from mpv's stream layer, which drives one stream from one thread at a time. The open callback runs a seek probe before the read and close callbacks are installed, and close runs after demux teardown. + +`cancel_fn` is left `None` so that nothing reaches the cookie from the cancel thread, which `stream_cb.h` documents as a second thread. Wiring it up would break the exclusivity described above; the cookie would need an atomic or a lock first. `assert_send::()` fails the build if a future field makes the cookie non-`Send`, which the compiler cannot otherwise check across a raw pointer. + +The open callback ignores the URL it is handed: the payload is fixed at startup, so there is nothing to parse. + +Order matters at startup: `loop://` is registered before `mpv_initialize`, so the protocol exists when playback starts. The first `loadfile` uses the synchronous `mpv_command`, before anything can hang; every later `loadfile` from an in-place recovery uses `mpv_command_async`, so a stuck core cannot freeze the supervisor thread. The rest of that behaviour is in [Failure handling at runtime](failure-handling.md). + +The event loop dispatches eight events and ignores the rest: + +- `NONE` and `START_FILE` continue. +- `SHUTDOWN` breaks the loop and tears mpv down normally. +- dexd forwards `LOG_MESSAGE` to standard error as `mpv/{prefix}: {text}`. +- `PROPERTY_CHANGE` carries the `time-pos` sample the health check runs on. `COMMAND_REPLY` reports whether a recovery's `loadfile` was accepted: dexd logs a rejection and clears the one `END_FILE(reason=stop)` that attempt would have produced. dexd judges the recovery by whether `time-pos` advances again. +- `END_FILE` is fatal unless it is the stop from dexd's own recovery. `QUEUE_OVERFLOW` is fatal in every case: mpv drops events once its queue fills, and the dropped one may have been the `END_FILE`. + +## Device layout + +| Path or identity | Shape | Purpose | +|---|---|---| +| `dex` | system user, no login, no home, groups `video` and `render` | opens `/dev/dri`; everything else is denied by the unit's sandboxing | +| `/opt/dex` | root-owned, mode `0755`, mounted read-only into the unit | the video, its sidecar and the exhibit config | +| `/var/cache/dexd` | created by systemd, owned by the service user | mpv's shader cache, through `XDG_CACHE_HOME` | + +`/opt/dex` is the mount point of the dex card's data partition, so the same three files are visible when the card is put in a computer. See [Exhibit config](exhibit-config.md). + +## Pass criteria + +The architecture is judged on correct playback: realtime rate, full frame rate, correct colour and correct geometry, with a gapless loop as one clause of it. mpv's own counters do not settle it: on the slow interop paths mpv reports zero dropped, decoder-dropped and late frames while presenting every frame below realtime. Playback time against the wall clock is the measure that separates them (see the [measurement record](measurements.md)). A run that fails one of the other clauses says nothing about the loop point and is not counted as loop-point evidence. + +dexd plays no audio. libmpv can play audio, so adding it later needs no change to this design; the roadmap is in [Roadmap and open questions](roadmap.md). + +## Alternatives + +Measured on a Raspberry Pi 4 at 3840×2160, 30 fps with one test video unless stated; the rows without a rate are failure modes. The playback-rate sampling script reads mpv on the plane path at 0.969× realtime; the script's own 0.2 s polling overhead is the assumed cause of the shortfall. The kernel vblank counter and mpv's own display-rate reading put that path at 30 fps with no dropped frames. Conditions in the [measurement record](measurements.md). + +| Option | Outcome | Why not | +|---|---|---| +| ffmpeg `vout_drm` | 1.92× realtime, the fastest playback path measured | its author calls it non-production; held frames at the loop point, measured at 1920×1080, 60 fps because an HDMI capture device cannot resolve single frames at 3840×2160 | +| GStreamer `kmssink` | fails to bind a SAND dma-buf, falls back to copying into ordinary CPU-allocated buffers and runs out of memory | an upstream gap confirmed by a Raspberry Pi engineer, not a tuning problem | +| GStreamer `v4l2slh265dec ! glupload ! glimagesink` | 0.97× realtime | a GL import through GStreamer's own upload path, so no headroom; mpv's GL interop is far slower on the same hardware | +| VLC `drm_vout` | 0.91× realtime | logs a failure to set the atomic capability and leaves the atomic path | +| mpv `hwdec=drm` with `vo=drm` | selects the software decoder without reporting it | not the plane path at all | +| A purpose-built player | reference implementations of the decode-to-plane path exist (`hello_drmprime`) | it would reimplement mpv's timing layer and plane management, the two hardest parts of the job | +| Python | 0.6× realtime, frames held at random points across the loop, not at the loop point | the read callback cannot sustain about 5 MB/s under the interpreter lock | + +The mpv choice is worth revisiting if its presentation path regresses on a future release, if a Raspberry Pi 5 needs a different interop, or if a held frame at the loop point appears that mpv cannot fix. diff --git a/docs/design/ci.md b/docs/design/ci.md new file mode 100644 index 0000000..9131123 --- /dev/null +++ b/docs/design/ci.md @@ -0,0 +1,205 @@ +# Continuous integration + +This page describes the workflow in `.github/workflows/dexd.yml`, which builds and checks the dexd Debian package for the Raspberry Pi players. It covers the build environment, what each job asserts and what a passing run establishes. It is for anyone changing the crate, the package or the workflow. + +## Build environment + +The package builds on an `ubuntu-24.04-arm` runner inside a `debian:trixie` container. The runner gives native arm64 with no emulation; the container gives Debian's libraries, the ones the players have. + +The pairing matters because the package derives `Depends:` from the shared libraries the binary links, through `dpkg-shlibdeps` (see [packaging](packaging.md)). Linking against Ubuntu's libmpv and installing on Debian would produce the version mismatch that a derived `Depends:` exists to catch. Re-check the pairing when the players move to a new Debian release, by running on a player: + +```sh +. /etc/os-release; echo $VERSION_CODENAME; dpkg -s libmpv2 +``` + +The codename must be the container's Debian release, and the player's libmpv2 at least the version the build log records. + +## Toolchain + +`rustc` and `cargo` come from Debian's archive inside the container. Trixie ships Rust 1.85, and `rust-version = "1.85"` in `Cargo.toml` states the same floor. The `build` job enforces it: a dependency needing a newer compiler fails the build here, not at deploy time on a player. + +cargo-deb is pinned to its 2.x line, because version 3.7 needs Rust 1.88. The install step tests for the binary itself rather than for `cargo-deb` on `PATH`: + +```sh +test -x "$HOME/.cargo/bin/cargo-deb" || cargo install --locked cargo-deb --version "^2" +``` + +`cargo` comes from apt here, so nothing puts `~/.cargo/bin` on `PATH`. On a cache hit `command -v cargo-deb` reports the binary missing while `cargo install` exits 101 with the binary already in place. `cargo deb` runs either way, because cargo searches `$CARGO_HOME/bin` for its subcommands. + +The `build` job installs build-essential, pkg-config, ca-certificates, git, rustc, cargo, rust-clippy, libmpv-dev, dpkg-dev, lintian and ffmpeg. + +The job records `dpkg -s libmpv2`, `rustc --version` and `cargo --version` in the log. When a later package refuses to install on a player, compare those lines with the player's own output first. + +`ffprobe -version` runs in the same step. The `dex-sidecar write` tests encode HEVC with ffmpeg and skip themselves when it is missing, and a skipped test still reports a pass. + +Cargo caches `~/.cargo/bin`, `~/.cargo/registry`, `~/.cargo/git` and `packages/dexd/target`, keyed on a hash of `Cargo.lock` with the prefix `dexd-deb-` as restore key. + +## Triggers and change detection + +The workflow runs on manual dispatch, on pushes to `main`, `experiment/**` and `feature/**`, on tags matching `dexd-v*`, and on every pull request. CI runs on branch pushes because a check that runs only at release time cannot prevent the release. Manual dispatch appears in the Actions interface only for workflows on the default branch. + +The workflow carries no path filters. A filtered workflow that does not run reports nothing, and a required check that never reports blocks every merge. + +The `changes` job decides what is relevant with `git diff` against a base commit. It checks out with `fetch-depth: 0`, because the diff needs history. + +`changes` publishes two flags: + +- `crate`, set when a path under `packages/dexd/` or the workflow file changed. `build`, `lint`, `deny` and `lifecycle` run on it. +- `docs`, set when `docs/`, `AGENTS.md`, `CLAUDE.md`, the docs lint tool or the workflow file changed. `docs-lint` runs on it. + +Three cases set both flags whatever the diff says: a manual dispatch, a tag build, and an unusable diff base. A release is built from the whole tree, a person pressing the button means run it, and an unknown diff must not skip a real change. The base is `github.event.pull_request.base.sha` for a pull request and `github.event.before` otherwise. An empty value, an all-zero hash, or a commit this checkout lacks — a new branch, a force-push, a first commit — is unusable. + +Workflow permissions are `contents: read`. + +The workflow groups concurrency per ref as `dexd-deb-${{ github.ref }}` with `cancel-in-progress`, so a push supersedes the previous run on the same branch. A tag build has its own ref, so the run that produces the release artifact survives an unrelated branch push. + +## The required check + +`gate`, displayed as **required**, is the single job to mark required in branch protection. It runs with `if: always()`, because a check that can be skipped can never be required, and it depends on `changes`, `build`, `lint`, `deny`, `lifecycle` and `docs-lint`. It decides in shell, which separates a legitimate skip from a failure and from a cancellation: + +- any needed job whose result is `failure` or `cancelled` fails the check; +- a `skipped` job fails the check when its flag was true, because a job skipped by a failed dependency is not a legitimate skip; +- otherwise the step logs `gate: PASS`. + +## Package build + +The `build` job compiles, tests and packages: + +```sh +cargo clippy --all-targets -- -D warnings +cargo test --release +``` + +The full suite runs here because the container has libmpv. A checkout on macOS cannot link the binary, so `cargo test --lib` is the most a developer machine runs; see [building and testing dexd](development.md). + +### Forced-recovery test + +One step runs a single test from `tests/cli.rs` by exact name: + +```sh +cargo test --release --test cli -- --ignored --exact force_recovery_survives_against_real_mpv +``` + +The test drives a real mpv core with software HEVC decode and `vo=null`, forces an in-place recovery, and asserts the process survives the `END_FILE` (reason=stop) event that its own recovery produces. Trixie's software decoder supplies the one environment-dependent ingredient, so no display, DRM device or GPU is needed. The test carries `#[ignore]`, so a plain `cargo test` never starts a real-decode run on a player in the middle of a long-running test; this step is where it runs. + +The step greps the output for `1 passed`. libtest exits 0 when a filter matches nothing, so a renamed or deleted test would leave the step passing having run nothing; requiring the string turns that into a failure. + +Breaking the check confirms it (see [Vacuous checks](#vacuous-checks)). Disabling the increment that marks a recovery's own `END_FILE` as expected fails this step in about three seconds. The clippy step and the rest of the suite still pass (see the [measurement record](measurements.md)). + +### Package assertions + +`cargo deb` builds the .deb with a per-build revision: + +```sh +short=$(printf '%s' "$GITHUB_SHA" | cut -c1-12) +cargo deb --deb-revision "${GITHUB_RUN_NUMBER}+g${short}" +``` + +The run number leads because dpkg compares runs of digits numerically, so version order follows time; a revision built from the commit alone does not (see [packaging](packaging.md)). + +dpkg-shlibdeps derives `>= 0.19.0` from the linked symbols, while `Cargo.toml` states `libmpv2 (>= 0.40.0)` because the requirement is behaviour, not symbols (see [packaging](packaging.md)). + +The job prints the package's fields and contents into the log, then asserts: + +| Assertion | Failure it catches | +|---|---| +| `Depends` mentions libmpv | dpkg-shlibdeps stopped resolving libmpv, so the package installs on a player that has none | +| `Depends` matches `libmpv2 (>= 0.40)` or higher | the `libmpv2 (>= 0.40.0)` line was dropped from `Cargo.toml`, so the package installs against a too-old libmpv | +| the first stderr line does not contain `nogit` | the packaged binary cannot name the commit it was built from | +| the first stderr line contains `($short)` or `($short+dirty)`, `$short` being the 12-hex commit | the same, positively: an empty string also lacks `nogit` | +| `Version` contains `$GITHUB_RUN_NUMBER+g$short` | apt treats the version as already installed and leaves the older binary running | + +The `Verify derived dependencies` step depends on three details: + +- `dpkg-deb -x` extracts the whole tree. Piping `dpkg-deb --fsys-tarfile` into `tar` fails, because the tarfile's members carry no `./` prefix, so `tar -xO ./usr/bin/dexd` matches nothing and exits 2. +- dexd prints its version and build identity to stderr as its first line, whatever the arguments, so the capture uses `2>&1`. Without it the captured string is empty, the `case` matches nothing, and the check reports success having observed nothing. +- `cut` truncates the commit hash. Run steps in a `container:` job execute under `/bin/sh` (dash on trixie), where `${VAR:0:12}` is a "Bad substitution" error. + +The workflow sets `DEX_BUILD_ID` to `github.sha`, and `build.rs` compiles it into the binary as its build identity. The workflow passes it in an environment variable rather than a build-id file. `rerun-if-changed` on a path absent when the cached build ran counts as unchanged. With a build-id file, a package built from a warm cache reports `(nogit)`. + +`lintian --tag-display-limit 0 --fail-on error,warning` then checks the package, with accepted tags and their reasons in `deploy/lintian-overrides`. The .deb is uploaded as the artifact `dexd-deb`, with `if-no-files-found: error`. + +## Static checks + +`lint` covers the files that ship without being compiled. It is a separate job, so it still reports when the package build breaks. It installs shellcheck, devscripts (for checkbashisms), systemd (for systemd-analyze), git, ca-certificates and reuse. + +- `systemd-analyze verify deploy/dexd.service`. systemd accepts a directive it does not recognise in a section without reporting it, so a typo or a misplaced key leaves the unit starting with the directive having no effect. The step fails on any output other than the two expected `Command ... is not executable` notices. Those binaries ship in the package, which this container does not install. +- `shellcheck deploy/dex-wait-hdmi deploy/maintainer-scripts/*` and `checkbashisms deploy/maintainer-scripts/*`. Maintainer scripts run as root on every player under `/bin/sh`, where a bashism fails the install. +- `reuse --root . lint`. Every file in the crate carries its copyright and licence machine-readably. The scope is the crate; the repository as a whole waits on the GPL-inherited packages. + +## Dependency policy + +`deny` runs `cargo deny check` against `deny.toml`: security advisories, a licence allow-list trimmed to what the dependency graph contains, and a ban on the procedural-macro toolchain. Without this job, nothing would enforce the decision to avoid derive macros. + +The job runs on the bare runner with rustup, outside the container. The Debian-compiler rule binds what builds the shipped artifact; cargo-deny produces nothing that ships and needs a newer rustc than trixie has. cargo-deb has the same property but builds the package, so that one is pinned instead. + +## Package lifecycle + +`lifecycle` downloads the `dexd-deb` artifact and installs, removes and purges it against a real dpkg in a clean trixie container. Maintainer scripts that fail, files that outlive a purge and a service user never created appear only on a real install and removal; no static linter reaches this class of bug. + +The job runs the install/remove/purge/autoremove cycle twice, the first time unobserved. Installing the package pulls in systemd, dbus and policykit. These are Debian-protected packages that never autoremove, and their own postinst scripts create state — a machine-id, the systemd catalog, enablement markers — that no dpkg file list mentions. A player already carries them in its base image, so the first cycle brings the container to that starting state and the diff compares like with like. + +The asserted cycle checks each step: + +| Step | Present | Absent | +|---|---|---| +| after install | `/usr/bin/dexd` and `/usr/bin/dex-wait-hdmi`, both executable; the `dex` user; `/opt/dex`; `/lib/systemd/system/dexd.service`; `/usr/share/man/man1/dexd.1.gz` | a video asset; an exhibit config; `/etc/dex` | +| after `apt-get remove` | the `dex` user; `/opt/dex` | `/usr/bin/dexd` | +| after `apt-get purge` | the `dex` user; `/opt/dex` | the unit file; `/etc/dex` | + +Group membership follows postinst's own conditional check, with one exception. udev creates `render`, and this container has no udev, so the job cannot assert membership in that group. Debian's `base-passwd` defines `video` in every Debian environment, so the job asserts unconditionally that `video` exists and that `dex` belongs to it. That keeps one real assertion in force. + +The postrm script leaves two things behind on purpose: `/opt/dex`, which contains the video, its sidecar and the exhibit config — none of which the package shipped — and the `dex` user, which anything written at the venue may name. + +A third leftover is a bug, so the job also compares filesystem snapshots from before the install and after the purge. `snap()` lists `find / -xdev` sorted, pruning `/proc`, `/sys`, `/run`, `/tmp`, `/var/log`, `/var/lib/apt`, `/var/cache`, `/var/lib/dpkg` and `/github`. `grep -v` removes the `/opt/dex` lines from both before the comparison. On any difference the step prints `::error::purge left files behind beyond the two intended:` with the diff. + +Snapshots go under `/tmp`, because a snapshot written to `/` shows up in the next one as a new file. `diff` reads them from temporary files, because run steps in a `container:` job execute under dash, which has no process substitution. + +## Documentation lint + +`docs-lint` runs on the plain runner with Node 20: first the lint tool's own tests, then the tool over the public documentation, the glossary and the writing rules. + +```sh +node --test scripts/docs-lint.test.mjs +node scripts/docs-lint.mjs --coinages docs/lint-coinages.tsv docs AGENTS.md +``` + +## Vacuous checks + +Confirm a check by breaking what it protects: disable the code path, confirm the step fails for the stated reason, restore the code, confirm it passes. A check that observes nothing looks the same as a check that passes. + +A vacuous check fails in one of two ways: + +| Shape | Examples | +|---|---| +| The check observes the wrong thing | a grep matching the echoed command instead of its output; an assertion on a variable that captured empty stderr | +| The check never runs | a path-filtered job; a tool with no build for the runner's architecture; an assertion on a group a minimal container cannot have | + +In the Actions interface, neither a job that never ran nor a job that passed reports a failure. + +A result from a developer's own machine is likewise a claim about that machine's toolchain. Clippy reports errors on Linux/aarch64 that macOS does not, because `c_char` is `u8` on one and `i8` on the other, and Debian's shellcheck reports findings other builds do not. The container's result decides. The code passes on every version of these linters, so none is pinned. + +## Limits + +A passing run establishes: + +- dexd builds with the compiler and the libraries the players have; +- its test suite and the forced-recovery test pass; +- the package installs, removes and purges on trixie/arm64, leaving only the two intended leftovers; +- the artifact carries a distinct version and a traceable commit. + +It does not establish that the picture comes back after a recovery. The forced-recovery test runs with `--no-defaults` and `vo=null`, so hardware decode, the drmprime-overlay interop and the plane swap are never exercised. A recovery that rebuilds that chain incorrectly leaves a black screen on a live process. Only a run on a Raspberry Pi with a display attached covers that — see [failure handling](failure-handling.md). + +No job runs on Raspberry Pi hardware, so playback throughput, loop-point behaviour and thermal results come from the [measurement record](measurements.md). The lifecycle job tests no upgrade from a previous version, and its filesystem diff is narrower than piuparts's leftover heuristics. + +## Alternatives + +| Option | Outcome | +|---|---| +| Path filters on the workflow | Rejected: a check that never reports blocks every merge | +| A third-party change-detection action | Rejected: `git diff` is a few lines and one dependency fewer | +| rustup toolchain inside the container | Rejected: the target's libraries with another compiler | +| Native build on the runner's Ubuntu image | Rejected: links against Ubuntu's libmpv | +| Cross-build or qemu on an x86 runner | Rejected: arm64 runners are free for public repositories | +| piuparts for the lifecycle test | Rejected: no installation candidate for the runner's architecture, and the container is already a throwaway environment | +| Pinning linter versions | Rejected: the code is version-independent instead | diff --git a/docs/design/development.md b/docs/design/development.md new file mode 100644 index 0000000..ef0a48a --- /dev/null +++ b/docs/design/development.md @@ -0,0 +1,157 @@ +# Building and testing dexd + +This page is for a developer with a checkout: which machine runs which command, how the tests are layered, and the rules that keep a test off a display in use. + +## Two machines + +Development uses a macOS workstation and a Raspberry Pi, and neither alone is enough. The Raspberry Pi has what the player needs: DRM and KMS, the Broadcom HEVC decoder, and Debian's libmpv. The workstation has none of them, so a checkout there cannot link the binary and `cargo test` fails at link time. + +Pure-logic tests run on either machine; anything that links libmpv runs on the Raspberry Pi or in CI. The workstation carries the capture and analysis side — see [Measurement record](measurements.md). + +## Checkouts + +Both machines have a checkout of the same repository and exchange work through the remote. On the Raspberry Pi, once: + +```sh +git clone --no-recurse-submodules https://github.com/KTE/dex.git ~/dex +cd ~/dex/packages/dexd +``` + +The clone skips submodules because `packages/example-content` carries video masters a build does not need; it is then about 9 MB. + +A checkout also gives the build its identity: `build.rs` reads the commit with `git rev-parse`, so the startup line names it — `dexd 0.1.0 (8b8c00ef5eef)`. See [Packaging](packaging.md#version-and-build-identity) for the other sources. + +Encoded test videos are build products, not repository content: keep them outside the checkout, for example under `~/assets/`. + +**Note:** a test that must outlive the SSH session that started it needs `loginctl enable-linger `; without it, systemd ends the user's session at the last logout and kills its processes. + +## Toolchain + +The Raspberry Pi builds with Debian's compiler from apt: trixie ships Rust 1.85, and `Cargo.toml` declares `rust-version = "1.85"`, which states the limit. Compiling with that toolchain, on the device and in CI, is what enforces it, so a dependency that needs a newer compiler cannot be adopted. A deployed Raspberry Pi is also a build host, so raise that floor only after confirming the devices can still build the package. + +On the Raspberry Pi (and in the CI container): + +```sh +sudo apt install build-essential pkg-config rustc cargo rust-clippy libmpv-dev ffmpeg +``` + +`libmpv-dev` is what the binary links; `ffmpeg` supplies the `ffmpeg` and `ffprobe` commands `tests/sidecar_write.rs` uses to make real HEVC streams. Building the .deb also needs `dpkg-dev`, `lintian` and cargo-deb from crates.io — see [Packaging](packaging.md). + +The workstation needs only a Rust toolchain at 1.85 or newer — usually rustup on macOS — because nothing it builds links libmpv or ships in the package. + +Everything else comes from cargo: four direct dependencies — `serde`, `serde_json`, `sha2` and `yaml-rust2` — plus their transitive closure in `Cargo.lock`. + +## Commands + +Run every command below in `packages/dexd`; there is no workspace root. + +| Machine | Command | What it covers | +|---|---|---| +| workstation | `cargo check --all-targets` | type-checks every target, including unlinkable ones | +| workstation | `cargo test --lib` | the library's tests, all the workstation can run | +| Raspberry Pi | `cargo test` | library, binary target and the three integration targets | +| Raspberry Pi | `nice -n 19 cargo test` | the same, kept off the CPU of a long-running test | +| Raspberry Pi | `cargo build --release` | the release binaries, including the two the package installs | + +## Test layers + +- Library tests cover the pure logic — `chunk`, `exhibit`, `ffi_consts`, `health`, `heartbeat`, `nal`, `sha256`, `sidecar` and `watchdog` — with no libmpv and no display, so `cargo test --lib` runs anywhere. The crate is cut so that everything decidable lives there — see [Architecture](architecture.md). +- Binary-target tests in `src/main.rs` call no mpv function but link libmpv, so they run on the Raspberry Pi; `cargo check --all-targets` type-checks them elsewhere. +- Integration tests spawn the binary or link the library: `tests/cli.rs` for failure paths and exit codes, `tests/ffi_constants.rs` for the hand-transcribed constants against the linked library, `tests/sidecar_write.rs` for `dex-sidecar write` against ffmpeg-made streams. + +Every test in `tests/cli.rs` but one asserts exit behaviour — the exit code, and that the process ended at all — because failure paths are where this program's defects occur. + +Two tests cover garbage input. `garbage_bytes_refused_at_startup_exit_2` proves the asset check refuses it, exit 2 and inside the deadline. `playback_failure_exits_nonzero_never_hangs` forces a failure after that check with `--opt vid=no`, so the process still has to exit once mpv is running. + +`DEXD_ALLOW_MEDIA_SKIP=1` skips the sidecar-writer tests where ffmpeg cannot make a stream; skipping is opt-in because a skipped test still reports a pass. + +## Display safety + +A Raspberry Pi under test may be showing something. All but one of the invocations in `tests/cli.rs` that can reach mpv creation carry: + +``` +--no-defaults --opt vo=null --opt vid=no --opt aid=no +``` + +The null video output never touches DRM, and deselecting every track makes mpv end deterministically (`NOTHING_TO_PLAY` → `END_FILE`) instead of playing on. + +One test departs from the rule. `force_recovery_survives_against_real_mpv` keeps the video track selected to observe a health check against playback that is advancing; `vo=null` keeps it headless, and its own time limit bounds the decode. It carries `#[ignore]`, so no plain `cargo test` starts it: + +```sh +cargo test --test cli force_recovery_survives_against_real_mpv -- --ignored --nocapture +``` + +Run it on a Raspberry Pi with nothing else on the display and no long-running test in progress. CI runs it by exact name in a step of its own. + +## Test harness + +`tests/cli.rs` spawns the binary found at compile time through `env!("CARGO_BIN_EXE_dexd")`, with stdout discarded and stderr piped; a thread drains that pipe, because a child that filled it would block and look like a hang. + +`run_with_deadline` polls the child every 50 ms and kills it on overrun. The deadline is what catches a player that hangs on a failure path instead of exiting. + +Three outcomes stay distinct: exited with a code, killed at the deadline, or died by signal. + +Scratch files go to `dexd-test--` in the system temporary directory, so parallel runs cannot collide. + +## Fixtures + +The HEVC fixture is a real stream: `stub_annexb()` returns the NAL units of a single-frame encode, parameter sets and one IDR slice, produced by: + +```sh +ffmpeg -f lavfi -i color=c=black:s=16x16:d=1:r=1 -frames:v 1 -c:v libx265 -x265-params keyint=1 -f hevc frame.265 +``` + +`loop://` never returns end of file, and the demuxer's probe gives up early only when it reaches one. Fed garbage, the probe extracts nothing and keeps requesting data — a full CPU core, memory growing, no output — until the deadline kills the run (measured on a Raspberry Pi). Real parameter sets let it resolve width, height and profile in one pass, so mpv reaches `END_FILE` in well under a second. + +## Test-only flags + +`--proc-cmdline PATH` supplies a synthetic kernel command line, so a test does not depend on the host's `/proc/cmdline`, which differs by machine. It is absent from the usage text: a deployment reads the real file. + +`--test-rig-force-recovery-after-secs` and `--test-rig-hang-after-secs` force a failure on purpose; each requires `--test-rig-no-sidecar --fps `, and [Failure handling](failure-handling.md) describes them. + +To see the systemd watchdog fire, run `--test-rig-hang-after-secs 0` under a throwaway unit with a short window. The recipe needs the package installed, an asset at `/opt/dex/artwork.265`, and the `dex` user and group the postinst creates: + +```sh +sudo systemd-run --unit=hang-test -p Type=simple -p NotifyAccess=main \ + -p WatchdogSec=15 -p Restart=on-failure -p RestartSec=2 \ + -p User=dex -p Group=dex -p SupplementaryGroups=video \ + /usr/bin/dexd /opt/dex/artwork.265 --test-rig-no-sidecar --fps 30 \ + --test-rig-hang-after-secs 0 --no-defaults --opt vo=null --opt vid=no --opt aid=no +``` + +`journalctl -u hang-test` then shows the watchdog timeout, the `SIGABRT` and the restart; the results are in the [measurement record](measurements.md). + +## Lints + +Four checks make up the lint standard: + +- `cargo clippy --all-targets -- -D warnings`, with Debian's clippy in the CI container deciding, because findings differ between clippy builds and platforms; +- `#![forbid(unsafe_code)]` in the library, so no module and no test module can lift it; +- `#![deny(unsafe_op_in_unsafe_fn)]` in `src/main.rs`, so every unsafe operation is scoped where it happens; +- `cargo deny check` for the dependency policy — see [Packaging](packaging.md). + +Miri is a follow-up: it cannot cross the FFI boundary, so its scope is the library. + +Documentation and commit conventions, and the `scripts/docs-lint.mjs` gate that enforces them, are in [`AGENTS.md`](../../AGENTS.md). + +## Regression proof + +Prove a regression test bites: re-introduce the defect, watch the test fail, then revert: + +- change `MPV_EVENT_LOG_MESSAGE` in `src/ffi_consts.rs` from 2 to 6 — `event_ids_match_the_live_library` fails on the Raspberry Pi; +- replace the `END_FILE` branch body in `src/main.rs` with `continue` — `playback_failure_exits_nonzero_never_hangs` fails at its deadline, after about 30 seconds. + +New work follows the same order: write the failing test, watch the assertion fail, implement the minimum that makes it pass, commit. + +## Portability details + +`c_char` is signed on macOS and unsigned on the Raspberry Pi. `read_fn` in `src/main.rs`, the stream read callback mpv calls for data, uses `buf.cast::()` rather than `buf as *mut u8`, which is a real conversion on macOS and an unnecessary cast clippy rejects on the Raspberry Pi; a bare `buf` compiles only on the Raspberry Pi. + +An `AF_UNIX` path is capped at the size of `sockaddr_un.sun_path` — 104 bytes on macOS, 108 on Linux. The macOS temporary directory alone comes close enough to produce `EINVAL`, so the watchdog's socket tests bind under `/tmp`, with a short fixed prefix and a per-process counter keeping every path inside the budget. + +## Alternatives + +| Option | Outcome | +|---|---| +| Copying the crate to the Raspberry Pi instead of cloning it | Rejected: without `.git` the build cannot name its commit, and a separate identity file has to be kept in step | +| rustup for the package build (CI container and device) | Rejected: "builds here" and "builds on a device" become two claims that drift — see [Continuous integration](ci.md) | diff --git a/docs/design/endless-stream.md b/docs/design/endless-stream.md new file mode 100644 index 0000000..2cd10df --- /dev/null +++ b/docs/design/endless-stream.md @@ -0,0 +1,98 @@ +# The endless stream + +This page explains how dexd repeats a video with no visible break at the loop point, what the method requires of the video file and why it uses none of the looping options mpv and ffmpeg offer. It is written for a developer reading the player's source. + +Measured numbers on this page come from a Raspberry Pi 4 with the 3-second test video; the conditions are in [measurements.md](measurements.md). + +## Method + +dexd gives mpv the video as one byte stream that never reports end of file, so the decoder neither seeks nor restarts. + +The decoder never re-initialises. The video's first picture is an IDR at the head of a closed GOP, so presenting byte 0 straight after the last byte is an ordinary mid-stream keyframe, and the decoder does no seek. Annex-B HEVC concatenates at the byte level, so the repeated bytes are a valid stream. + +## Requirements on the video + +- A raw Annex-B elementary stream, not MP4. ffmpeg writes it directly with `-f hevc`; to take an existing HEVC stream out of a video container, run a one-off `-c:v copy -bsf:v hevc_mp4toannexb` — see [prepare-video.md](../guides/prepare-video.md). +- A closed GOP whose first picture is an IDR, preceded by the VPS, SPS and PPS parameter sets. +- A frame rate stored beside the file, because a raw stream carries no timestamps. dexd passes the sidecar's rate to `--container-fps-override` together with `--no-correct-pts`; without both, mpv guesses a rate and plays at the wrong speed. + +An open GOP would make the first picture depend on pictures that no longer exist when the stream returns to byte 0, producing a visible glitch at every loop point — about 29,000 a day for a 3-second video (derived). + +dexd checks the parameter sets and the first slice at startup and refuses a video that does not begin with an IDR. That check and its exit code are in [startup-checks.md](startup-checks.md), the frame rate and the checksum in [sidecar.md](sidecar.md). + +## loop:// stream + +dexd registers the `loop://` scheme with libmpv before mpv initialises, then plays the URL `loop://endless`. At startup it reads the whole video into memory once, as the payload: 1.3 MB at 1920×1080 and 14.8 MB at 3840×2160 for the 3-second test video, so no file I/O happens at the loop point. + +When mpv opens the URL, dexd's open callback creates a per-stream state with the payload and the position, and installs four callbacks. mpv passes the state back on every call. + +- `read_fn` copies the requested bytes and moves the position back to byte 0 when it reaches the end of the payload, instead of returning 0. Returning 0 would tell mpv the file has ended. +- `seek_fn` returns `MPV_ERROR_UNSUPPORTED` (-18), mpv's documented "not supported" return for a stream callback, so the stream behaves like a pipe. If the stream reported itself seekable, mpv would seek at the loop point, and that seek leaves the last frame on screen too long — see [Loop-point stalls](#loop-point-stalls). +- `size_fn` returns the same error. A length would let mpv compute a duration and a playback position for a stream that has neither, and mpv could treat the end of the payload as the end of the media (not tested). + +The stream cannot seek, so mpv offers no scrubbing. That suits a player that only ever loops and rules this configuration out as a general-purpose one. + +## Loop position arithmetic + +`chunk.rs` computes the position: `next_chunk(len, pos, want) -> Option`, tested without libmpv. `len` is the payload length, `pos` the reader's position and `want` the bytes mpv asked for. `Chunk` carries the offset to copy from, the byte count and the position afterwards. The read callback is a thin unsafe shell that performs the copy, under the bounds `next_chunk` guarantees: at least one byte, never more than mpv asked for, inside the payload and never overlapping the destination. + +- The byte count is `min(want, len - start)`, so `next_chunk` answers a request larger than the bytes remaining with a short read. mpv's `stream_cb.h` permits that, so no single call spans the return to byte 0: the tail comes now, the head on the next call. +- The position after the call is always below `len`. The return to byte 0 happens in the call that reaches the end, never in the following one, so the bounds the unsafe copy relies on hold between calls. +- `None` means `want == 0` or an empty payload; the caller turns it into an mpv error, never 0. mpv 0.40's `stream.c` rejects zero-length reads before invoking the callback, so the branch never runs. +- `clamp_want` converts mpv's `u64` request size with `usize::try_from(nbytes).unwrap_or(usize::MAX)`. `as usize` would truncate a request of an exact multiple of 2^32 to zero bytes on a 32-bit target and end the stream. On the aarch64 target the conversion always succeeds, so the test states the contract rather than exercising it (not tested). + +One test drives `next_chunk` over a 997-byte payload with six request-size schedules: one byte at a time, the payload length itself, one short of it, one past it, 4096 bytes and a deterministic pseudo-random schedule of 2000 sizes. It asserts the output equals the payload repeated endlessly, byte for byte. Further tests lock in the boundary cases: the point where the position returns to byte 0, a payload smaller than the request and the zero-length request that must not become a zero-byte answer. + +## Read-ahead + +dexd sets mpv's `--demuxer-readahead-secs=1.0`, the prefetch depth: one second of demuxed packets ahead of the decoder, across the loop point. mpv runs its larger cache only for streams flagged as network, and a stream-callback stream is not one. `--demuxer-readahead-secs` is therefore the bound that applies, and `--demuxer-max-bytes=64MiB` is a second safeguard. + +dexd's resident memory stays flat at 221 MB over 20 s playing the 3840×2160, 30 fps test video (see [measurements.md](measurements.md)). + +## Loop counter + +The read callback increments a counter on the demux thread each time the position returns to byte 0; the supervisor thread reads it for the heartbeat, which prints it as `loops=` — see [reference.md](../guides/reference.md#system-log-lines). Relaxed ordering is enough for a monotonic diagnostic. The counter counts demuxer passes, which run about one second ahead of what is on screen. + +## Loop-point stalls + +With `--loop-file=inf`, mpv leaves the final frame on screen for 83 ms against 33 ms for every other frame. The extra 50 ms falls once per loop, so a 3.000 s video repeats every 3.050 s, which reads as a hesitation in smooth motion (assumed). The frame is the last frame of the loop every time: ten of ten loops in one run, with no frame skipped, and 61 of 61 in a longer one. mpv's drop counters stay at zero, so nothing is dropped. + +The seek causes the held frame. `--ab-loop-a`/`--ab-loop-b` seeks before the end of the file and leaves the same frame on screen just as often, which rules out mpv's end-of-file path specifically. A file containing the same video twelve times over, decoded straight through with no seek, showed no held frame: 597 distinct frames seen at the HDMI output, each for one frame time, across seven loop points. At the loop point mpv seeks and decodes a fresh IDR, and the picture already on screen stays there while that happens (derived). + +The continuous run played the same encoded video through the same decoder and output path, so the Raspberry Pi 4 presents 30 fps content with correct frame timing. Seek-based looping causes the defect, and a gapless loop must reach the next repeat's first frame without a seek. + +Each mechanism stalls where it does its work: at the seek, at the playlist open or on the keyframe. + +| Mechanism | Where it does its work | Held frame | Position | +|---|---|---|---| +| mpv `--loop-file=inf` | seek at end of file | 83 ms, once per loop | last frame of the loop | +| mpv `--ab-loop-a` / `--ab-loop-b` | seek before end of file | 83 ms, once per loop | last frame of the loop | +| mpv `--playlist` with `--prefetch-playlist=yes` | opens the next playlist entry | 117–133 ms, once per loop | last frame of the loop | +| ffmpeg `-stream_loop -1 -f vout_drm` | re-enters the file | 67–217 ms, three times per loop | at keyframes, not at the loop point | +| one continuous decode of concatenated content | none | none | — | + +On a raw .265 file, `--loop-file=inf` freezes on the last frame instead of looping (see [measurements.md](measurements.md)). + +The endless stream was measured against `--loop-file=inf` on the same video, player and display mode, differing only in how the video reached mpv. It left no held frame: none across 8 loops at 1920×1080, 60 Hz output (30 fps content, so every frame is captured twice), and none across 19 loops at 3840×2160, 30 Hz. The seeking configuration held the last frame in 10 of those 19 loops. + +At 3840×2160 the HDMI capture device used for these runs delivers about 27 fps against 30 fps content — the [capture deficit](../glossary.md). The 4K count therefore corroborates the oversampled 1920×1080 runs rather than standing on its own. + +The endless-stream runs fed the bytes with the shell pipeline under [Alternatives](#alternatives). dexd's `loop://` callback emits the same bytes: its tests assert that `next_chunk` reproduces the video repeated endlessly, byte for byte. + +The two players fail in different places. mpv decodes IDR frames without stalling and stalls only when it re-enters the file. ffmpeg's direct-to-DRM output (`vout_drm`) stalls on IDR frames instead, with or without an endless stream, so its stalls fall away from the loop point. mpv on a stream that never ends has neither stall. + +## Alternatives + +The project judged Cog, VLC, mplayer, hello_drmprime and a GStreamer 1.22 attempt on screen in 2024, on a Raspberry Pi Zero 2 W with H.264 and H.265 test videos. + +| Option | Outcome | Why not | +|---|---|---| +| `while true; do cat video.265; done \| mpv -` | gapless — the comparison under [Loop-point stalls](#loop-point-stalls); no memory growth over 3.5 h | spawns about 29,000 processes a day for a 3-second video and busy-loops on a full CPU core if mpv exits | +| hello_video | gapless, and the player of the legacy dexOS image | H.264 only, no audio | +| omxplayer | about 100 ms of black between plays (assumed) | removed from Raspberry Pi OS since bullseye (Debian 11) | +| pivid | purpose-built gapless player for installations | 32-bit builds failed, project dormant | +| Cog on WPE WebKit | gapless for H.264 with occasional hiccups | HEVC through the same path stutters | +| VLC | not gapless | gapless mode was expected only in VLC 4, unreleased at the time | +| hello_drmprime | not gapless | the zero-copy reference program, not a player | +| mplayer | did not play | did not work out of the box | +| a GStreamer 1.22 attempt | not gapless | stuck on the first frame and dropped the display between plays | diff --git a/docs/design/exhibit-config.md b/docs/design/exhibit-config.md new file mode 100644 index 0000000..63f053e --- /dev/null +++ b/docs/design/exhibit-config.md @@ -0,0 +1,230 @@ +# Exhibit config + +The exhibit config is the file that says which video plays and how the display is driven. This page covers where it lives, its two formats and shared schema, the grammar behind each key, and how dexd and dex-exhibit-apply(1) keep it in agreement with the kernel command line. It is written for a developer reading `exhibit.rs` or diagnosing a player that will not start; the technician's version is [Configure the exhibit](../guides/configure-exhibit.md). + +## Config scope + +The display mode is a property of the installation, not of the video and not of the service unit. One video runs on several displays over its life and one display shows several videos over a season, so whoever changes the mode edits the copy the player does not read as soon as the pairing changes. + +A 4K capture device advertises 3840×2160 at 30 Hz as its own preferred timing, and the vc4 driver builds no 3840×2160 mode from that advertisement; with the mode forced in `cmdline.txt`, the driver builds that same 297 MHz timing and it works. A 2560×1440 monitor whose EDID never mentions 2160 must carry no force, or the Raspberry Pi transmits a signal the display cannot show. One `video=` token in `cmdline.txt` is therefore right for one venue and wrong for the next, and deleting it changes the target (measured on a Raspberry Pi 4; conditions in [measurements.md](measurements.md)). + +The `asset` key completes the pairing of display and video: one file names the venue's display and the video that plays on it. `ExecStart=/usr/bin/dexd` passes no arguments, so the unit states how to run the player and the exhibit config what it plays and where. Several videos can sit in `/opt/dex`, with the config choosing one. + +## Config location + +dexd looks for `/opt/dex/exhibit.yaml`, then `/opt/dex/exhibit.json`; `--exhibit-config PATH` overrides both. `/opt/dex` is the mount point of the dex card's data partition, so the card in a computer shows the video, its sidecar and the config together; the unit waits for that mount (see [service-unit.md](service-unit.md)). + +The package installs no exhibit config: no default file, no file dpkg would preserve across upgrades, no `/etc/dex` directory. A player without one refuses to start and prints the path to create together with a two-line example (see [startup-checks.md](startup-checks.md)). + +Exactly one of the two names may exist, and dexd refuses when both do, printing both paths. YAML is searched first, so a `.json` beside it reads as the leftover copy and the message adds `sudo rm ` for it, since a config switched from JSON to YAML beside its predecessor is the likely cause. Nothing in the order decides between two files that both exist. + +Discovery is the one part of `exhibit.rs` that touches the filesystem, and both dexd and dex-exhibit-apply(1) call it. Two copies would drift, and the apply tool would then reconcile the command line against a file the player does not read. + +Discovery separates "not there" from "cannot tell": only a metadata call reporting the file absent means absent, and every other error is reported with its own message. + +## File format + +The file extension decides the parser: `.json` is read as strict JSON, `.yaml` and `.yml` as YAML, matched without regard to case. dexd refuses an unrecognised or absent extension before reading a byte, whatever the contents would parse as. The extension comes from `Path::extension`, so a dotfile named `.json` has none. + +YAML is a superset of JSON, so one YAML parser would read both names; dexd refuses YAML syntax in a `.json` file all the same. Strict JSON inside a `.yaml` file parses identically under both parsers, which lets a tool that writes the config emit one format under either name. + +Below the dispatch the two paths share every decision: both produce the flat key/value list the sidecar's JSON parser produces, and one function maps and validates it, so every key name, default, grammar check and message exists once. About forty lines of tree-building are all that differ, and two files expressing the same config parse to equal structures and produce identical messages for the same mistake. + +The flat subset carries strings and non-negative integers, one level deep. dexd refuses each of the following, with its own message: + +- a nested mapping or a list; +- a `---`-separated multi-document stream; +- a decimal, a boolean or a negative integer; +- a file that is empty or contains only comments, as having no document. + +A YAML feature belongs in the subset only if the same config could be written in the `.json` form; one that could not would make a config's meaning depend on the name it was saved under. + +dexd refuses anchors and aliases at the parser-event level, before a tree is built, because the loader replaces `*name` with a copy of the anchored node: a loaded document that used an alias is indistinguishable from one that spelled the value out. An anchor with no alias is refused too, since an alias needs an anchor to refer to. + +A syntax error is left to the loader, so the operator gets its marked diagnostic and not a vaguer message from the anchor check that runs first. + +dexd refuses duplicate keys in both formats: yaml-rust2 errors on inserting one instead of taking the last, and the JSON path uses a hand-written parser callback for the same rule. + +Measured against yaml-rust2 0.11: its scalar resolution follows the YAML 1.2 core schema closely, and tests lock the two places where it matters, because a version that adopted YAML 1.1 resolution would change what a deployed config means. + +- Only `true` and `false` are booleans, so `kms_force: no` arrives as the string `no`, and dexd refuses it, naming the valid values. +- Null resolution covers `null`, `~` and an empty value, case-sensitively, so `Null` and `NULL` arrive as ordinary strings. + +dexd refuses unknown keys, unlike the sidecar's tolerant schema: a refusal names `kms_forse` rather than dropping the forced display mode it was meant to set. + +dexd refuses an unparseable config and a missing one alike, and guesses no display. + +## Config keys + +| Key | Type | Default | Checked against | +|---|---|---|---| +| `asset` | string | none; see Asset resolution | the asset grammar | +| `display_mode` | string | required | the display-mode grammar | +| `kms_force` | string | `none` | the `kms_force` grammar | +| `connector` | string | `HDMI-A-1` | the connector grammar | +| `display` | string | none | type only | +| `venue` | string | none | type only | +| `note` | string | none | type only | + +The last three are informational: dexd parses and type-checks them and uses them for nothing else. A wrong type draws a refusal per key, in the same words in both formats: `venue: 2026` and `"venue": 2026` both report that `venue` must be a string, and quoting is the fix in either. What each key means for a technician is in [../guides/reference.md](../guides/reference.md). + +## Display mode + +`display_mode` is `auto`, or `WxH@R` with W, H and R positive integers — a run of digits containing at least one nonzero, so leading zeros are accepted. The `@R` half is mandatory, because `3840x2160` on its own lets mpv choose among same-resolution timings by list order. + +The refresh is an integer, and dexd refuses both fractional forms when it parses the config. Measured on a Raspberry Pi 4 running mpv 0.40 (see [measurements.md](measurements.md)): a rational refresh such as `3840x2160@30000/1001` fails mpv's option parser (`error setting option (-7)`) and produces a restart loop instead of a picture. A decimal such as `3840x2160@29.97` parses and plays the rounded 30 Hz mode that `@30` names, because mpv matches modes by rounded integer refresh. An integer refresh the connector does not offer fails later, at video-output init (see [startup-checks.md](startup-checks.md)). + +`kms_force` is `none`, or `WxH@R` with an optional trailing `D`, R again an integer because the kernel's `video=` grammar has no fractional refresh. The `D` is a suffix on the whole token, not part of the refresh. + +`D` makes the connector read as connected before a display is attached, which puts the boot-order fix at the KMS layer: a player switched on before its display no longer depends on which came up first. dex-wait-hdmi(1) is the fallback for a connector carrying no force, and storing a copy of the display's EDID for the kernel to read is an optional install-time step documented there. + +`connector` is `HDMI-A-` with n a run of digits. dexd refuses lowercase and a trailing space, and accepts a leading zero and `0` itself, because connector numbering is an index, not a rate. The same value spells the `video=:…` token dex-exhibit-apply(1) writes and the `/sys/class/drm/card*-` path the mode pre-flight reads (below), so a value accepted loosely here would fail far from where it was typed. + +dexd also accepts `--mode WxH@R` on the command line, as a cross-check against `display_mode`. The decision table matches the frame rate's in [sidecar.md](sidecar.md): + +| Config | `--mode` | `--test-rig-no-sidecar` | Result | +|---|---|---|---| +| present | absent | no | the config binds | +| present | equal | no | the config binds, cross-checked | +| present | different | no | refused, naming both | +| absent | any | no | refused: no exhibit config | +| any | valid | yes | the command line binds | +| any | invalid | yes | refused, naming the grammar | +| any | absent | yes | the mode is `auto` | + +`--test-rig-no-sidecar` (test rig only) also skips the cmdline check, so on that branch the resolved `kms_force` is `none` and the connector the default, since no config was consulted. + +Three options carry the result to mpv. `--container-fps-override` takes the resolved frame rate and `--drm-connector` the resolved connector, both on every run. `--drm-mode` takes the resolved display mode unless that is `auto`, in which case dexd passes no such option and mpv applies its own default of the connector's preferred mode. + +The mode pre-flight compares the resolution half of `display_mode` against the connector's own mode list in `/sys/class/drm/card*-/modes` (see [startup-checks.md](startup-checks.md)). + +`auto` names no resolution, so dexd skips that pre-flight and compares the asset's width and height, stored in the sidecar, with the connector's mode list instead, warning and naming a forced mode as the repair. Whether that should be a refusal is open; see [Open questions](#open-questions). + +## Asset resolution + +`asset` is a non-empty path with no trailing slash and no control characters. The asset grammar checks neither existence nor extension: the startup check opens the file and reports the read error, which says more than a grammar refusal would. dexd refuses a control character because it prints this path to the system log at every start, where a newline would forge a second log line. + +A relative `asset` resolves against the directory the config file sits in, so `asset: artwork.265` beside `/opt/dex/exhibit.yaml` names `/opt/dex/artwork.265`, and the service's working directory never enters into it. An absolute path is used as given. + +| Config `asset` | Command-line path | `--test-rig-no-sidecar` | Result | +|---|---|---|---| +| present | absent | no | the config binds | +| present | equal | no | the config binds, cross-checked | +| present | different | no | refused, naming both | +| absent | present | no | the command line binds, logged as such | +| absent | absent | no | refused: nothing names a video | +| any | present | yes | the command line binds; the config is ignored | +| any | absent | yes | refused: on this branch the path must be on the command line | + +That resolution happens before the cross-check, so a relative key and an absolute command-line path naming one file agree. + +Nothing defaults to a fixed asset path: a mistyped key would otherwise play the previous season's video, with nothing in the log to show for it. The refusal prints the line to add in both formats, and the package's postinst script names the three files a player needs at install time: the video, its sidecar and the config. + +A path given on the command line is a one-off: a test rig, or trying another file on a deployed device without editing the config. dexd logs the source with the path at startup, so a hand-started run cannot be read as evidence about the deployed one. + +Because the asset comes from the config, an empty command line is the shipped invocation: a bare `dexd` reaches the config and refuses there when the config names no video. + +## Kernel cmdline + +dexd compares the config's `kms_force` with the running kernel's command line at every start. `kms_force: none` expects no `video=` token for the connector; any other value expects that exact token. The comparison reads `/proc/cmdline`, because an edit to the file on the boot partition takes effect only at the next boot. + +dexd ignores other connectors' `video=` tokens and every other kind of token, so another connector's token counts as absent. If an operator edits one side and forgets the other, dexd refuses at the next start instead of playing on whatever mode the kernel booted with. + +Every refusal here names both repairs, because the check cannot tell which side is stale; the reasoning is in [startup-checks.md](startup-checks.md). + +A `video=` token with no connector prefix, such as `video=1920x1080@60`, is a shape the kernel's grammar also accepts, and it forces every connector. Neither the check nor the rewrite can say which connector it binds, or how the kernel would arbitrate it against a per-connector token, so both refuse when one is present and quote it verbatim. + +The rewrite itself is a pure function in `exhibit.rs`, which dex-exhibit-apply(1) calls. It rewrites the `video=:…` token for one connector to match `kms_force`, leaving every other token, its position and every other connector's token untouched. A `kms_force` of `none` removes the token; a connector that has none yet gets one appended. Replacing in place, not appending at the end, makes the rewrite idempotent: a second run finds the token already correct and changes nothing. + +Two rewrites of one command line for connector `HDMI-A-1`, by the value of `kms_force`: + +``` +input console=ttyS0 video=HDMI-A-1:3840x2160@30 rootwait quiet +2560x1440@60 console=ttyS0 video=HDMI-A-1:2560x1440@60 rootwait quiet +none console=ttyS0 rootwait quiet +``` + +The rewrite refuses four inputs: text containing an embedded newline, a `kms_force` outside its grammar, a connectorless `video=` token, and a result that would leave the file empty. A trailing newline is trimmed, since an editor may have added one. + +## dex-exhibit-apply + +dex-exhibit-apply(1) writes the boot command line from the exhibit config. dexd runs as an unprivileged user under `ProtectSystem=strict` and never writes boot config (see [service-unit.md](service-unit.md)), so applying a config change is a separate, privileged step an operator runs by hand: + +``` +sudo dex-exhibit-apply [--exhibit-config ] [--cmdline-path ] +``` + +dex-exhibit-apply checks for root before it touches any file, so a run without it fails early instead of dying half-way through on a permission error. `--cmdline-path` defaults to `/boot/firmware/cmdline.txt` and exists for a test rig. An unrecognised flag, a missing value and `-h` print usage and exit 2. + +The tool loads the config through the same discovery dexd uses, one-file rule included, and names the file it read before reporting what it did. A missing config is a plain refusal here, since the tool has nothing else to do. A command line that already matches prints `no change` and exits 0 without writing. + +dex-exhibit-apply writes a change in four steps: + +1. a backup of the current file, which it fsyncs before touching the original; +2. a sibling temporary file, fsynced; +3. a rename over the original; +4. a best-effort fsync of the parent directory. + +The rewritten file keeps the original's trailing-newline convention. + +dex-exhibit-apply ignores a failure of the last step, because the data and the file are committed by then. Rewriting in place would truncate first, so a power cut between truncate and commit would leave a zero-length `cmdline.txt` and a Raspberry Pi that will not boot. + +Backups are named `cmdline.txt.bak-`, with a `.1`, `.2` and so on when that name is taken, so two applies in the same second cannot truncate each other's backup. The five newest are kept, because the boot partition is small. + +Pruning orders candidates by modification time, never by name: a name sort would delete the newest backup, since a reused unsuffixed name sorts before its own `.1` and `.2` siblings. The backup just written is excluded, because the boot partition's filesystem records modification times to two seconds and can tie two backups from one second. Pruning is best-effort and never fails an apply that has already succeeded. + +A first apply, with no old backup to prune: + +``` +dex-exhibit-apply: applying /opt/dex/exhibit.yaml (connector HDMI-A-1, kms_force 3840x2160@30D) +dex-exhibit-apply: /boot/firmware/cmdline.txt + old: console=ttyS0 rootwait quiet + new: console=ttyS0 rootwait quiet video=HDMI-A-1:3840x2160@30D + backup: /boot/firmware/cmdline.txt.bak-1786968000 +REBOOT REQUIRED -- dexd binds the display from the running kernel's /proc/cmdline +``` + +A run that prunes prints ` pruned old backup: ` after the `backup:` line, once per file removed. The last line appears only when the file changed: an unrebooted change has no effect, and the next start's cmdline check reports the mismatch. + +| Code | Meaning | +|---|---| +| 0 | The command line was reconciled, or already matched | +| 1 | A file could not be read or written | +| 2 | Refused: not root, an invalid config, or a rewrite the tool refuses (a connectorless `video=` token, an empty or multi-line result) | + +## Upgrades and leftovers + +dexd does not read a config left under `/etc/dex` by an older installation; the postinst prints a note naming the move when it finds one. + +A systemd drop-in is a `.conf` file under `/etc/systemd/system/dexd.service.d/` that overrides unit settings. One that still passes `--mode` either agrees with the config, which is harmless until it is removed, or disagrees, in which case the display cross-check refuses at the next start and names both values. The postinst prints the warning and does not fail the install, since the package must not delete a file an administrator created: + +``` +sudo rm /etc/systemd/system/dexd.service.d/*.conf && sudo systemctl daemon-reload +``` + +## Test scope + +The grammars, the resolution tables, the cmdline comparator, the rewrite and the pre-flight parser are pure functions; discovery and the write path are not, and the split against the tests that spawn the binary is in [startup-checks.md](startup-checks.md). The list of default paths is injectable, so the discovery policy is testable against a temporary directory. + +The tests that spawn the binary use a config fixture of `display_mode: auto` with a synthetic kernel command line carrying no `video=` token, so the cmdline check and the mode pre-flight both pass on any host, whatever its own boot state. + +Tests lock in the YAML library properties the schema depends on, so a version bump cannot change what a deployed config means without failing one. + +## Alternatives + +| Option | Outcome | Why not | +|---|---|---| +| One YAML parser for both names | Comments, anchors and unquoted keys accepted inside a `.json` file | Other programs read the file by its name | +| YAML's JSON schema for the `.json` path | Comments, block style and anchors still accepted | The schemas govern scalar resolution, not syntax, and yaml-rust2 offers no selection | +| Anchors and aliases inside the subset | Nested aliases expand exponentially during loading, before any node-type check runs | The one unbounded allocation on the startup path | +| Unknown keys tolerated, as in the sidecar | `kms_forse` drops the forced display mode it was meant to set, with no error | The file has no independent producer to stay compatible with | +| Two config files resolved by precedence | The player reads one file while someone edits the other | The refusal names both files; deleting one is the repair | +| `Path::exists` for discovery | A permission error on a parent directory reads as "no config" | The message would name a file to create that the operator can already see | +| A fractional refresh in `display_mode` | A rational never plays; a decimal plays its rounded integer | Measured on a Raspberry Pi 4 (see [measurements.md](measurements.md)) | +| dexd writing `cmdline.txt` itself | The player would need write access to the boot partition | dexd runs unprivileged and sandboxed, so applying is a separate tool | +| A fixed default asset path | A mistyped `asset` key falls back to the default | It would play the wrong video with no error | + +## Open questions + +- Whether `auto` should refuse instead of warning when the connector cannot offer the asset's resolution, and whether `auto` should stay the value a fresh config is written with. +- Whether the pre-flight should cover the refresh half as well, by enumerating modes through mpv or parsing them when the command line is applied. An unoffered integer refresh already fails at video-output init, so the extension would only change whether mpv or dexd prints the error, and would catch no new class of mistake. diff --git a/docs/design/failure-handling.md b/docs/design/failure-handling.md new file mode 100644 index 0000000..3f3bcf4 --- /dev/null +++ b/docs/design/failure-handling.md @@ -0,0 +1,204 @@ +# Failure handling + +This page describes how a running dexd notices that it has stopped showing pictures, what it does about it, and which failures it cannot see. It is for a developer reading `main.rs`, `health.rs`, `heartbeat.rs`, and `watchdog.rs`. + +Terms are defined in [the glossary](../glossary.md); measured numbers and their conditions are in the [measurement record](measurements.md). The refusals that happen before mpv exists are on [Startup checks](startup-checks.md), and the unit settings named here are explained line by line in [The systemd unit](service-unit.md). + +## Recovery layers + +Four layers answer a fault; two are built. + +| Layer | Mechanism | Recovers | +|---|---|---| +| In-place recovery | dexd re-issues `loadfile` on its own stream and keeps running | a stalled demux or decode chain | +| Process restart | `Restart=always`, `RestartSec=2` in the unit | anything the process cannot repair in place | +| Reboot escalation | planned | state that a restart does not clear, such as a stuck DRM device | +| Hardware watchdog | planned | kernel hangs and total lockup | + +When a layer fails, the next one runs. `Restart=always` with `StartLimitIntervalSec=0` never gives up, so the process comes back, whichever layer repairs the fault. + +The health check that drives the first layer runs on the supervisor thread, off the decode path, and never blocks presentation (decided). + +## Fatal events + +An `END_FILE` event from mpv is fatal, because dexd's stream has no end. The read callback wraps to byte 0 instead of reporting end of file (see [Why an endless stream](endless-stream.md)), so playback ending means something failed. dexd logs the reason and calls `exit(1)`: + +``` +dexd: fatal: playback ended (reason=2, error=success) -- an endless stream must never end; exiting so systemd restarts this process +``` + +dexd absorbs one kind of `END_FILE`: the one its own recovery causes ([below](#expected-end-of-file)). + +dexd creates its mpv handle with idle mode on, so libmpv emits `END_FILE` for a failed load and stays idle, never emitting `SHUTDOWN`. + +Display-side failures also arrive as `END_FILE`. With mpv's normal video output, `vo=gpu`, mpv creates the video output during the file load, not during handle setup. A projector that is not awake, a connector with no EDID, or a getty still holding DRM master therefore pass both the handle-setup and the `loadfile` return codes and land as `END_FILE`. + +`MPV_EVENT_QUEUE_OVERFLOW` is fatal too. mpv's internal event ring fills at 1000 pending events and drops every event after that — possibly an `END_FILE` — until the client drains it, and reserves no slots for fatal events. What was lost cannot be known, so dexd exits. + +dexd exits with `std::process::exit(1)` on a fatal event and on an exhausted recovery budget, never through the shared `mpv_terminate_destroy` teardown. Only a genuine `SHUTDOWN` breaks the loop and tears down normally. A rejected mpv option is an operator error and exits 2 instead (see [Startup checks](startup-checks.md)). + +Removing the end of file also removes the point at which mpv detects many failures: a stream that probes as HEVC but never yields a decodable frame can leave mpv buffering with no event at all (assumed). The health check notices that. + +A test forces a display-free failure (`vo=null` with every track deselected, so mpv reaches "nothing to play") and asserts exit code 1 within a 30 s deadline; a player still running when it expires is killed and the test fails, so a hang on a failure path cannot pass. + +## Health check + +The health check judges whether playback is advancing, using only values mpv pushes. dexd registers one `mpv_observe_property` call for `time-pos` at startup, which does not block. It then reads the position out of `MPV_EVENT_PROPERTY_CHANGE` events on the same wait loop that detects the fatal events. No synchronous property read exists in the runtime path. + +That also makes an unresponsive mpv core visible: when the core stops, no property-change events arrive, and the absence is the stall signal. + +Every 10 s, one tick feeds the latest known position to the policy in `health.rs`: + +- Two consecutive ticks whose position does not strictly increase count as a stall; one does not, which absorbs jitter around a check boundary. Detection latency for a real stall is therefore about 20 s (derived). +- A position that goes backwards — a property glitch during a video-output reconfigure — counts as a stall, because progress requires a strictly greater position. +- The first sample after start or after a recovery counts as progress. mpv's startup latency before a 4K decode begins is a few hundred milliseconds to a few seconds (assumed), and that grace keeps it from reading as a fault. +- A tick with no sample counts as a stall and gets no first-sample grace, so a startup that never produces a position is still caught after two ticks. + +`mpv_wait_event` uses that cadence as its timeout. In healthy playback mpv delivers position events often enough to wake the thread on its own. During a stall no events arrive at all, and the timeout guarantees the tick still happens. The wake costs one event drained and two numbers compared, on the supervisor thread. + +If the `time-pos` subscription fails to register, dexd creates no health monitor, skips the tick, and warns once that in-place recovery is off for this run and process restart still applies. Without that guard the position would stay unknown forever, every tick would read as a stall, and dexd would issue recoveries against a healthy player and eventually exit. + +## In-place recovery + +dexd answers a qualifying stall by re-issuing `loadfile loop://endless replace` on its own stream and continuing. The command goes through `mpv_command_async`, never the blocking `mpv_command`, used once at startup, because the call runs on the supervisor thread that must keep detecting fatal events. + +A `loadfile ... replace` tears down and rebuilds the demuxer and the decoder chain, and forces a video-output reconfigure. It does not tear down the video output itself: mpv tears the video output down only at process termination (documented in mpv 0.40's source). A fault in the DRM or GPU context can therefore survive the replace and still need a process restart. + +`MPV_EVENT_COMMAND_REPLY` is diagnostic; nothing gates on it. The next tick judges the recovery by whether the position starts advancing again, and a logged rejection makes that judgement traceable afterwards. + +The reload re-opens the stream from byte 0, so mpv's position counter restarts near zero. Both the policy and its driver forget the pre-recovery position when the attempt is issued. Keeping it would make the next sample read either as a continuing stall or as a fresh first sample that stretches the escalation timeline. + +### Recovery budget + +Attempts come from a budget of 3 for the whole process lifetime, cumulative across separate stall episodes and never refilled. Three bounds the worst case and still absorbs a handful of isolated glitches over a multi-week run. + +When the budget is spent, the next qualifying stall escalates: dexd logs the exhausted budget and the reason, then exits 1. + +dexd clears the consecutive-stall counter before each attempt or escalation, so the next judgement gets its own two-tick window; the budget is untouched by that reset. + +### Expected end-of-file + +`loadfile ... replace` makes mpv emit `END_FILE` with reason `stop` (value 2) for the file being replaced, confirmed live against mpv 0.40.0 on a Raspberry Pi (measured). dexd absorbs that event and continues: + +``` +dexd: health check: in-place recovery's loadfile replaced the stream; absorbing the expected END_FILE(reason=stop) for the file it replaced (0 more still outstanding), not treating it as a failure +``` + +The absorption test is a pure function of two values: a pending count above zero and reason `stop`. Every other reason stays fatal, and so does a `stop` with nothing pending — the case in the fatal line above. Nothing else in the program issues a command that produces a stop-reason end-of-file, so the count tells dexd's own teardown apart from a failure carrying the same code. + +The pending value is a count. The health-check tick and the forced-recovery probe can each queue a recovery in the same loop iteration. `mpv_command_async` only queues against a core that may still be busy, so two recoveries can be in flight, each producing its own stop event. A flag would absorb the first and treat the second — the teardown of a recovery that just worked — as fatal, killing a healthy-again process. + +The count is incremented only after the command is queued, because only then is a stop event coming. It is decremented by one on each absorbed stop or on a rejected command reply, which keeps a second attempt's stop expected. + +mpv exposes no runtime name lookup for end-of-file reasons, so the stop reason's value is transcribed by hand. A wrong value fails in the safe direction: the absorption never matches and the stop falls through to the fatal path. + +## Heartbeat + +The heartbeat is one line in the system log every 600 s, frequent enough to bound when a player died to a useful window: + +``` +dexd: heartbeat loops=143 uptime=3600s temp=48.2C frame-drops=0 vo-delayed=2 pos=3599.4s pos-age=0s watchdog=armed pings-dropped=0 +``` + +The heartbeat never calls into mpv: the function that emits the line has no mpv handle, and `mpv_get_property_string` and `mpv_free` are absent from the FFI surface, so the call cannot be written. A synchronous property read waits on a condition variable with no timeout until mpv's core thread reaches its dispatch loop (documented in mpv 0.40's source). A core stuck in a display call against a projector that has stopped responding never reaches that point. + +dexd learns every value the line prints from an event, which is safe on the supervisor thread. Observed-property getters run on the core thread with the client lock dropped, so a stuck getter blocks neither the supervisor thread nor `mpv_wait_event`: an unresponsive core delivers no further events. + +mpv generates property-change events inside `mpv_wait_event` once the queue has drained and never queues them, so observing more properties cannot push dexd toward the fatal queue overflow. + +An observer costs two events per counter around startup: an initial notification with no value, then the first real value once the video-output chain exists. After that an event arrives only when the value changes. + +mpv's drop counters are per playback session and restart at 0 when a recovery rebuilds the chain, so dexd accumulates. It adds forward deltas and reads any decrease as a session reset whose post-reset value is new, so `frame-drops=0` cannot be a false all-clear after a recovery. + +A counter reads `n/a` until its first value, and `off` when the subscription never registered. `mpv_observe_property` never validates a property name, so a rename upstream would subscribe cleanly and sit at `n/a` for the whole run. When mpv reports a counter as unavailable — no video-output chain at startup, or during a recovery's teardown — dexd clears the diffing baseline and keeps the total already earned. + +The position uses a fixed one-decimal format, so a position from weeks of uptime stays readable (three weeks reads `pos=1814400.0s`), and `pos-age=` separates a healthy player from one that has stopped reporting. The chip temperature comes from `/sys/class/thermal/thermal_zone0/temp` in millidegrees; where the kernel does not expose it, the field reads `n/a`. + +The first heartbeat follows the `loadfile` and proves temperature reading and line formatting on every boot. It does not prove the subscriptions: the counters and the position read `n/a` there because nothing has decoded. During the long-running test on a Raspberry Pi 4, every heartbeat from t+600 s carried numeric counters and `pos-age=0s` (measured; see the [measurement record](measurements.md)). + +## Systemd watchdog + +The watchdog covers a hazard neither the health check nor the heartbeat can see: dexd's own supervisor thread hanging in code that is not an mpv call. The canonical case is a log write blocking against a system log that has stopped responding, including on the escalation path that exits so the service manager can take over. Detecting that needs an external actor. + +The unit carries `WatchdogSec=180` and `NotifyAccess=main`, and `Type` stays `simple`. A notify unit that never sends `READY=1` sits inactive forever, and dexd has no ready moment before the endless stream starts, so `READY=1` is never sent. `STOPPING=1` is never sent either, because there is no graceful shutdown. + +The 180 s window exceeds the worst case of a full in-place recovery episode (about 2 minutes, derived), so a watchdog kill cannot pre-empt a recovery that would have finished. + +dexd sends `WATCHDOG=1` once per 10 s tick, after that tick's evaluation and any recovery command have completed, and from nowhere else. + +That ordering means a broken player stops pinging. Because the budget never refills, a player whose display has stopped responding delivers no further events, stalls, recovers at most three times, and exits. That is a bounded number of pings, then either an exit the restart policy handles or, on the one path that can still hang, no more pings. + +The ping is sent even when the health check is disabled for the run. It then certifies only that the loop completed an iteration, and the startup warning says so. + +### Ping protocol + +The whole wire protocol is the literal bytes `WATCHDOG=1`, with no trailing newline, sent to `$NOTIFY_SOCKET` over an AF_UNIX datagram socket. It is written by hand against `std` alone — `UnixDatagram` covers the abstract-namespace case too — and adds no crate to the package. + +Unix datagram sockets have flow control, so a blocking send against a full receiver queue would block the supervisor thread. The socket is opened once, in non-blocking mode, and held for the life of the process. Any send failure — a full queue, a socket path that vanished — counts as a dropped ping, never retried inline, never a panic, and surfaces in the heartbeat as `pings-dropped=`. + +The handshake resolves once at startup, before the first heartbeat, so that line already carries the real state. dexd logs each inert case once rather than warning: + +- No `$NOTIFY_SOCKET`, or an empty one: inert. This is the common case — a development machine, a test session, CI, any invocation off systemd. +- `WATCHDOG_PID` set and not this process: inert, because pinging under another identity would be wrong. A value that does not parse is treated the same way. +- Otherwise armed, with the window taken from `WATCHDOG_USEC`. A window shorter than twice the tick cadence produces a warning, and pings continue regardless; 180 s against a 10 s cadence gives 18 pings per window and no warning. An unparseable value arms with no window and no invented warning. + +Setup itself can fail — address resolution, socket creation, setting non-blocking mode. When `$WATCHDOG_USEC` is present, systemd's kill timer is already running, whatever dexd logs. dexd exits 1 and lets `RestartSec=2` retry. Running without pings under an armed timer means a kill every window: the screen goes black every 3 minutes under the shipped unit (derived). + +With no timer armed, dexd logs once and runs without pings. The pid-mismatch case adds a warning about the coming kill loop when the timer is armed, so the log explains the restarts that follow. + +Never add a final ping to an exit path. A ping sent just before a hang on that path would reset the countdown. + +A watchdog kill is recovered by `Restart=` like an exit-code failure, confirmed on a Raspberry Pi (measured), so it needs no special handling. When reboot escalation is built, a watchdog-caused restart should count toward its window like any other. + +## Test-rig probes + +Two flags force a failure on purpose. Both require `--test-rig-no-sidecar`, are refused without it, print a loud warning at startup, and appear in no deployment. + +**`--test-rig-force-recovery-after-secs N`** forces the same recovery decision a real stall produces, once, drawing from the same budget and performing the same baseline reset. The health-check tick and the probe route through one function, so the probe drives the identical mpv-facing mechanics; only the log prefix differs. It fires N seconds after the `loadfile` request is queued, not after N seconds of confirmed playback, so a small N can fire during decode startup. + +**`--test-rig-hang-after-secs N`** parks the supervisor thread forever N seconds after startup, reproducing a hang outside any mpv call. It is checked before the event dispatch, so a run that reaches a fatal event immediately cannot win the race, and it never resumes: only an external actor ends the process. Run under a temporary unit with a short `WatchdogSec=`, it produces a watchdog timeout, a `SIGABRT`, exit status `6/ABRT`, and a restart in the system log — the procedure is in [Building and testing dexd](development.md). + +The automated tests stop short of the picture. CI runs the forced recovery against a real mpv under software decode with `vo=null` ([Continuous integration](ci.md)). It proves that the process survives its own recovery, that the position resumes advancing, and that no second recovery fires. + +It does not prove that the picture returns on hardware: `hwdec=drm`, `gpu-hwdec-interop=drmprime-overlay`, and the plane assignment are all skipped in a container with no DRM device and no GPU. Two things a container cannot reach are verified by hand on a Raspberry Pi: the picture after a recovery, and the kill after a hang. + +## Diagnostics + +libmpv discards every diagnostic it produces unless the client asks for it: `terminal=no` is its default, so log output goes nowhere unless requested as events. dexd requests level `warn` and forwards each message as `mpv/: `. A failed log request or property registration warns and is never fatal, because the health check and the counters are diagnostics on top of a working player. + +Every failure class reaches the system log and nothing else. There is no getty on tty1, conflicted away so the player can take DRM, so at a venue every failure looks the same: a black screen. An on-site fault signal is listed in the [roadmap](roadmap.md). + +## Device-level failures + +Some failures belong to the device, not to the running player. + +- A Raspberry Pi that boots before its display is awake reads no EDID and lands on a 1024×768 fallback, and the console does not re-set the mode once the display appears. Players are switched off at the mains, so this is the normal case; the fix is a forced display mode in `cmdline.txt`, covered in [Exhibit config](exhibit-config.md). +- Without `hwdec-software-fallback=no`, a decoder that cannot reach the hardware path falls back to software with no error and plays 3840×2160, 30 fps at about 14 fps (measured). dexd makes that fatal, turning an invisible collapse into an `END_FILE` it can restart from. +- Power is cut at the mains with no graceful shutdown. Existing installations have survived that cycling on the current image arrangement (decided), so dexd does not require a read-only root filesystem. That evidence comes from dexOS images; a dexd card is plain trixie plus the .deb, so a card that comes back corrupt is a reason to revisit it. +- A mains cut during an in-place write of `cmdline.txt` would leave it truncated and the Raspberry Pi unbootable. `dex-exhibit-apply` therefore writes a temporary file, fsyncs it, renames it over the original, and fsyncs the directory (see [Exhibit config](exhibit-config.md)). +- Boot output stays visible: no quiet boot (decided). + +## Residual gaps + +| Gap | State | +|---|---| +| Signal-level failure: HDMI signal lost, panel powered off, plane presenting to a disconnected display | Out of scope. The position keeps advancing, the health check reads healthy, pings continue, and the display stays black. Polling DRM connector status from the health tick — the same sysfs files the mode pre-flight reads — would close it. | +| Two overlapping recoveries | Tests enforce that both stop events are absorbed; the count path has not run on a device, because nothing recovered during the long-running test. | +| The health check disabled for a run | The ping then certifies only that the loop iterates, so a player whose display stops responding pings forever. The startup warning is the only trace. | +| Whether in-place recovery repairs a DRM or GPU fault | Not tested. A physical HDMI-loss test on a Raspberry Pi would settle it. | +| Startup grace on a slow display | Not measured. A display needing longer than the two-tick window (about 20 s) for a first position sample would draw a recovery mid-startup. 4K decode startup runs 1–3 s behind `dex-wait-hdmi`'s wait for the display (measured). | +| The "no second recovery" assertion in the forced-recovery test | It needs the loop still ticking; a loop that stopped iterating right after the absorb would satisfy every assertion with nothing running. A per-tick liveness line while the probe is armed would close it. | +| Drop-counter under-count across a recovery | Narrowed, not closed. mpv coalesces property events, so a teardown, a restart at 0, and a climb past the old total between two drains can hide the decrease. | + +## Alternatives + +| Option | Outcome | +|---|---| +| Wait only for `SHUTDOWN` and ignore `END_FILE` | Rejected: libmpv idles instead of exiting, so a failed load leaves the process alive with a black screen. | +| `break` into the shared mpv teardown on a fatal event | Rejected: `mpv_terminate_destroy` joins mpv's threads and can block on the hang it is trying to escape. | +| Read properties synchronously for the heartbeat | Rejected: the read hangs on the fault it reports. | +| Refill the recovery budget after a healthy period | Rejected: a flapping fault would reset the counter before it reached the cap, leaving the retry total unbounded. | +| `StartLimitAction=reboot` for reboot escalation | Not used: it interacts badly with `StartLimitIntervalSec=0`. A second unit triggered by `OnFailure=` is the planned shape. | +| A libsystemd binding or the sd-notify crate for the ping | Rejected: a binding adds a shared-object link to the package's derived dependencies, and a crate leaves the ping policy, the handshake, and the non-blocking audit here anyway. | +| Stop pinging when the health check is disabled | Rejected: it turns a degraded but working run into a guaranteed kill every window. | +| Suppress the second recovery when one is already in flight | Rejected: absorbing both stop events is simpler than preventing the overlap. | diff --git a/docs/design/measurements.md b/docs/design/measurements.md new file mode 100644 index 0000000..c5542ba --- /dev/null +++ b/docs/design/measurements.md @@ -0,0 +1,327 @@ +# Measurement record + +This page records every number the dexd documentation relies on, with the conditions it was taken under and how it was established, for a reader checking what a figure rests on. Other pages state a number and link here. + +Unless a line says otherwise, the hardware is a Raspberry Pi 4 Model B running Debian trixie and the video is HEVC in a raw Annex-B stream. A number with no other label is measured; *derived*, *assumed* and *not tested* mark the rest. + +## The instrument + +The measurement rests on a test card carrying its frame index as a [barcode](../glossary.md#barcode) burned into every frame, beside a grey ramp, colour wheels, a checkerboard border, resolution wedges and a set of rotating hands. A capture device — an Elgato Cam Link 4K — records the player's HDMI output. One script decodes the barcode into a stream of integers; a second turns that stream into a verdict and touches no hardware, so the analysis runs against synthetic input and replays any recorded run. The analysis tooling is not part of the dexd package. + +The analysis reads the decoded index stream one consecutive pair at a time: + +| Index behaviour | Reading | +|---|---| +| steps by 1 | a normal transition | +| repeats | a held frame | +| skips forward | a dropped frame | +| resets to 0 | a loop point | +| undecodable | a decode failure, counted against the side it falls on | + +The verdict is a comparison, never an absolute count: the capture device is not frame-locked to the player, so it drops and repeats frames on its own, spread evenly through the run, while a defect at the loop point concentrates there. The rate away from the loop point is the [noise floor](../glossary.md#noise-floor), measured on the same run by the same instrument. + +### Verdicts + +A [two-proportion test](../glossary.md#two-proportion-test) compares the anomaly rate at loop-point transitions with the rate at all other transitions in the same run. + +| Verdict | Condition | +|---|---| +| PASS | p ≥ 0.01 — the loop-point rate is indistinguishable from the noise floor | +| FAIL | p < 0.01 with the loop-point rate higher | +| VOID | p < 0.01 with the loop-point rate lower, meaning loop points are misclassified — check the loop length | +| INSUFFICIENT | fewer than 500 loop-point transitions captured | + +Sensitivity scales with the noise floor: against a perfectly clean sample away from the loop point, one anomaly in 600 loop points gives z = 5.4, p ≈ 7×10⁻⁸, while the same defect passes against a realistic 1% floor. A cleaner capture path makes the test stricter, so improving one mid-experiment invalidates comparison with earlier verdicts. + +**Note:** at a 1 s loop, one defect in 600 loop points is a visible stutter every ten minutes. The 1 s 4K30 test video runs 3,600 loops per hour, so 500 loop points take under ten minutes of capture (derived). + +### Two channels + +The measurement records two signals: mpv's own reporting, which needs no capture device, and the capture chain. They share no code, and a disagreement between them is itself a finding. One 4K30 run read as follows. + +| Signal | Channel | Reading | +|---|---|---| +| mpv's playback position against the wall clock, sampled by a script | mpv | [realtime rate](../glossary.md#realtime-rate) 0.476 — 14.3 fps of a required 30 | +| mpv's frame counters | mpv | dropped 0, decoder-dropped 0, late 0 | +| the decoded barcode | capture | advanced about 14 indices per second | + +mpv's counters alone cannot establish correct playback: at 14.3 fps it presented every frame, far too slowly, and every counter above read 0. Only playback time against the wall clock catches that. + +### Pass criteria + +The analysis checks correct playback first; a failure there makes the run VOID for loop-point purposes rather than a loop-point failure. + +| Clause | How it is checked | +|---|---| +| Realtime rate | mpv's playback position against the wall clock; ratio ≥ 0.98 | +| Full frame rate at the display | the captured index steps by 1, agreeing with the realtime-rate clause | +| Colour | the grey ramp and the colour wheels against the source | +| Geometry | the checkerboard border complete on all four edges, and the resolution wedges — converging line patterns that go grey where detail is lost | +| Native resolution | TMDS character rate and framebuffer size, with the wedges legible | + +A person checks the last three clauses by eye. A configuration that cannot reach correct playback fails outright. + +**Note:** the card's own frame counter resets at the loop point, a large visible discontinuity at the same place as an accidental one. For a comparison by eye, watch the rotating hands. + +### Controls + +The noise floor above is the first control, measured on every run. Two more run first, and both must pass before any hardware number is taken. + +| Control | What it establishes | Result | +|---|---|---| +| Planted defects | the analysis catches what it should, and only that | 1% of frames duplicated at random positions → PASS; one held frame in 600 loops → PASS; a held final frame at every loop → FAIL; a deterministic mid-loop anomaly → VOID | +| Positive control | the analysis invents no defects on a known-good loop | the legacy hello_video player on its own raw H.264 test video passes; if not, measurement stops | + +A fourth check needs no hardware: one verified single-loop capture, concatenated 600 times as a perfect player would put it on the wire, analyses as PASS. + +### Test videos + +The pipeline renders the card, burns in the barcode and encodes the result as HEVC with a closed GOP — keyframe interval equal to the frame rate, scene-cut detection off, open GOP off, IDR at frame 0, silent. It then decodes each encode again and reads the barcode back: a barcode that failed without a message would make every measurement from that file wrong. + +Each card yields two variants: a clean one at 3–9 Mbps, isolating the loop point, and a cloud-textured one at 20–39 Mbps, loading the decoder. Flat colour and static geometry compress to roughly a tenth of what real video produces — 3.1 Mbps at 1080p against a 20 Mbps target, 8.5 Mbps at 4K against a 40 Mbps target. Clean passing and textured failing means decoder load. + +Test videos exist at 1, 2 and 3 s, at 1080p, 4K30 and 4K60. The 4K reference video is 3840×2160 at 30 fps, 90 frames per loop, 39.7 Mbps, three closed-GOP keyframes, IDR at frame 0, barcode verified 0–89 on every frame after encoding. + +The card's motion returns to its starting position at the loop point. The rotating hands advance 6° per frame, one full revolution every 60 frames, and stand at 354° on the last frame of that revolution, so the step back to 0° is +6° like every other. A 4K30 file is decimated from a 60 fps render, exact for synthetic content with no motion blur; the pipeline refuses a non-integer decimation factor. + +## Playback-path throughput + +Conditions: Raspberry Pi 4, 3840×2160 at 30 fps, the ~39 Mbps cloud-textured test video, output to the capture device at 3840×2160 in RGB 4:4:4 — one full colour sample per pixel — at 8 bits per component, TMDS character rate 297 MHz. + +| mpv path | What touches the frame | Reading | +|---|---|---| +| `--vo=drm --hwdec=drm` | nothing decodes in hardware — mpv selects the software decoder | ~5 fps | +| `--gpu-hwdec-interop=drmprime` | the GPU samples the SAND-tiled frame as a texture | ~5 fps | +| `--hwdec=drm-copy` | the CPU detiles SAND into linear NV12 | 14.3 fps (ratio 0.476) | +| `--gpu-hwdec-interop=drmprime-overlay` | nothing — the frame handle goes to a KMS plane | 28.4 fps (ratio 0.947) | +| the same plus `--video-sync=display-resample` | nothing — mpv also paces presentation to the display's measured refresh | 29.1 fps (ratio 0.969), zero drops | + +The decoder emits SAND-tiled NV12, which the display block scans out natively only from a KMS plane; every other path detiles first, and detiling costs most of the frame rate. dexd uses the last row's options (see [architecture.md](architecture.md)). + +The overlay path runs at 30 fps. Three instruments independent of the capture device agree: + +- the kernel's count of display refresh intervals over 10 s reads 29.9993 Hz; +- mpv's estimated display frame rate reads 30.000002, and its `vsync-jitter` property reads 0.000183, a fraction of one refresh interval; +- the HVS underrun counter reads 0, the chip reporting no throttling at a 550 MHz core clock. + +The sampling script's 0.2 s polling overhead accounts for the residual 3% in the 0.969 ratio (assumed): 29.1 fps is that script's number, 30 fps the display's. Capture agrees — 317 frames captured, 317 decoded, none undecodable, 89 of 90 distinct indices present. + +### Other players + +Same hardware, same video, same output mode. + +| Player and sink | Throughput | Outcome | +|---|---|---| +| ffmpeg `-f vout_drm` | 1.92× | fastest measured, but stalls on keyframes three times per loop; described upstream as a development test device, not production-grade (see [vout_drm](../glossary.md#vout_drm)) | +| GStreamer, decode only to a null sink | 1.90× | the stateless decoder alone, no display path | +| GStreamer `glimagesink` | 0.97× | works, but imports through the GPU and leaves no headroom | +| VLC `--vout drm_vout` | 0.91× | logs a failure to set the atomic capability and leaves the atomic path | +| GStreamer `kmssink` | fails | cannot bind a SAND dma-buf, falls back to CPU copies and runs out of memory at 4K | +| pivid | not tested | purpose-built for gapless playback, dormant since 2024 | + +### Decode ceiling + +The decoder alone, with no display attached. + +| Content | Throughput | Implied rate (derived) | +|---|---|---| +| 4K30, 39.3 Mbps | 1.36× | ~41 fps | +| 4K at 40 fps | 1.08× | ~43 fps | +| 4K60 | 0.753× | ~45 fps | + +The Pi 4 decodes roughly 41–45 fps of 4K HEVC, the bound before any display cost. Through the working display path mpv plays the 4K60 file at a ratio of 0.976 and drops 83 frames — it keeps pace with the clock by discarding frames. + +Throughput tracks pixels per second, not the resolution label: 4K30 is 249 [Mpix/s](../glossary.md#mpixs), 1440p60 is 221, 4K60 is 498. About 250 Mpix/s is the practical Pi 4 budget for a full player pipeline (derived); [pi-capability.md](pi-capability.md) sets these beside vendor claims and independent reports. + +### Bitrate and sink + +Neither the bitrate nor the sink moves the frame rate much; the per-frame 4K copy sets it. + +| Change | Reading | +|---|---| +| Bitrate 39.3 → 3.1 Mbps, same 3840×2160 30 fps closed-GOP file | 14.3 → 15.2 fps, a 6% gain | +| The 4K file into the capture device | 14.3 fps | +| The same 4K file into a 2560×1440 monitor | 13.9–14.6 fps, 14–17 drops | +| A 1080p file into that monitor | 28.5 fps, zero drops | + +That copy scales with pixels times frame rate, so encoder settings cannot fix a detiling path. + +**Note:** forced to 3840×2160, that 1080p file is software-upscaled fourfold and runs slower than the 4K file; the 28.5 fps reading needs an output mode near the source resolution. + +### Memory and process cost + +| Quantity | Value | Conditions | +|---|---|---| +| Resident memory, dexd | flat at 221 MB over 20 s | 4K30 test video; mpv's read-ahead bounded (`demuxer-readahead-secs=1.0`, `demuxer-max-bytes=64MiB`) | +| Loop payload in memory | 1.3 MB at 1080p, 14.8 MB at 4K | a 3 s test video, read once at startup | +| The same design written in Python | 0.6× realtime | frames held at random points, the signature of a data source not keeping up | +| `while true; do cat loop.265; done \| mpv -` | no held frames, no memory growth over 3.5 h | the shell pipeline the endless stream replaces | + +## Loop point + +Held frames at the loop point, per looping mechanism. Conditions: Raspberry Pi 4, 4K30 and 1080p60 output, HDMI capture, the 3 s test video. + +| Mechanism | Held frame | Per loop | Where | +|---|---|---|---| +| mpv `--loop-file=inf` | 83 ms | 1 | the loop's last frame | +| mpv `--ab-loop-a`/`--ab-loop-b` | 83 ms | 1 | the loop's last frame | +| mpv `--playlist` with `--prefetch-playlist=yes` | 117–133 ms | 1 | the loop's last frame | +| ffmpeg `-stream_loop -1 -f vout_drm` | 67–217 ms | 3 | at keyframes, not at the loop point | +| a file concatenated twelve times, decoded continuously | none | — | — | +| dexd's endless stream | none | — | — | + +All three mpv mechanisms re-enter the file — one seeks at end of file, one seeks before it, one opens the next playlist entry — and all three stall. [endless-stream.md](endless-stream.md) describes the stream that replaces them. On a raw .265 file, `--loop-file=inf` freezes on the last frame. + +At 1080p60 output the device delivers every frame, so 30 fps content is [oversampled](../glossary.md#oversampling) twofold: every source frame occupies exactly two captures, and a held frame four or more — the ones measured here occupy five. Read `2 ×5497` in the [frame-duration histograms](../glossary.md#frame-duration-histogram) below as 5497 source frames of two captures each. + +| Run | Captures per source frame | Held frames | +|---|---|---| +| 61 loops, 11302 captured frames, no decode failures | 2 ×5497, 5 ×60, 4 ×1 | index 89 on 61 of 61 loops | +| 30 s, 10 loops | 2 ×873, 5 ×10 | index 89 on 10 of 10 loops | +| 7 loops, seeking at end of file, and 7 seeking before it | 2 ×582, 5 ×7 each | index 89 on 7 of 7 loops | +| 7 loops, concatenated file, no seek | 2 ×597 | none | +| 8 loops, endless stream | 2 ×749, 1 ×2 | none | + +Five captures at 60 Hz is 83.3 ms against 33.3 ms for every other frame, so the last frame stays on screen 50 ms too long and the loop period is 3.050 s against a nominal 3.000 s (derived). + +At 4K30 the capture is not oversampled and carries the instrument's ~10% deficit, which raises the noise floor. Over 19 loops the same comparison gives index 89 held ten times with a seeking configuration and no held frames on the endless stream. + +The statistical run the verdict rules were written for — 500 or more loop points at 4K30 — has not been run. Three results stand in its place: 19 loops at 4K30, the deterministic oversampled runs above, and the counters from the twenty-five-hour run below. + +## Long-running tests + +### Twenty-five-hour run + +Conditions: a Raspberry Pi 4 Model B Rev 1.1 on Debian 13 trixie, kernel 6.18.34+rpt-rpi-v8. dexd was installed from its .deb, started by systemd 11 s after boot. The asset: 3840×2160 at 30000/1001, 25.3 Mbps, 39.015 s per loop, frame rate from the sidecar. The output: a zero-copy KMS plane into the capture device, 4K30 forced display mode. Total run 25 h 30 min 03 s. + +| Quantity | Result | +|---|---| +| Loop count | 2355 | +| Dropped frames | 0 on every heartbeat reporting a number | +| Late frames | 0 throughout | +| Service restarts | 0; start timestamp at boot, one boot record for the window (no reboot) | +| Resident memory | 322244 kB on all 1416 samples, minimum equal to maximum — a 25.3 Mbps 4K30 asset, where the 221 MB above is the lighter test video | +| Chip temperature | 40.8–45.2 °C from the heartbeats, the three highest inside the first half hour | +| Throttle bits | never set | +| Processor use | 24.0–24.2%; the processor clock at its 700 MHz floor on 1350 of 1416 samples | +| Heartbeat continuity | 154 lines, largest gap 601 s | + +Loop count times loop length reconciles with uptime to 100.08% — 2355 × 39.015 s = 91880 s against 91803 s — a stall check needing no capture. + +The media clock finished 90 s ahead of the wall clock: the final heartbeat reads uptime 91803 s and position 91892.9 s, a ratio of 1.00098 — the 1001/1000 factor between 29.97 and 30 Hz, to within measurement. The video is tagged 30000/1001 against a nominal integer 30 Hz forced display mode, so presenting one frame per display refresh runs 29.97 content 0.1% fast. That accumulates as slow clock skew, which is why both drop counters read 0 while the clocks diverge. + +The project registered nine criteria before the run. Seven are met outright: restarts, boot count, heartbeat continuity, dropped frames, late frames, loop count (2217 at 24 h against a threshold of 2190) and playback-position freshness, `pos-age=0s` on every line. Two are open — resident memory, met on the evidence but open on the letter, and the human observation: + +| Criterion | Status | +|---|---| +| Resident memory: slope below 0.5 MB/h from t+1h to t+24h, growth under 25 MB | Telemetry started 1 h 56 min after the service did, covering 23 h 37 min — 92.6%, unbroken, largest sample gap 61 s — and the value is bit-identical across all 1416 samples, showing no growth. The criteria's telemetry clause voids the verdict because the gap exceeds ten minutes, and the disagreement is unresolved. | +| A human watching at start, middle and end | No observation recorded. | + +The run departs from the criteria in two ways, and leaves one measurement out: + +| Departure | What it means | +|---|---| +| Asset and sink | It played the 4K30 video into the capture device; the criteria named a 45 Mbps 1440p60 video on a 2560×1440 monitor. 4K30 is 248.6 Mpix/s against 221.0, so the run subsumes the lighter one on pixel rate by 12.5% — but not on bitstream load, at 25.3 Mbps against a 45 Mbps cap. | +| Build identity | The criteria pinned a build whose `--version` reported `0.1.0 (nogit)`; the build that ran is three commits later and self-reports its commit. | +| Capture omitted | The capture device cannot resolve a held frame at 4K, so every number above is the player reporting on itself, cross-checked against capture during the loop-point measurements. | + +### Sealed-enclosure thermal test + +Conditions: Raspberry Pi 4 in a sealed passive case, no fan, an unheated room in August; a 2560×1440 video at 60 fps into a sink forced to 3840×2160 at 30 Hz; sampled every 30 s for 2 h 04 min. + +Result: 199 loops, zero dropped and zero late frames at every heartbeat, throttle bits never set across all 249 samples, peak 78.4 °C, twenty-minute means plateauing at 77.2 °C. The Pi 4 soft-throttles at 80 °C, so a sealed case leaves 1.6 °C. A warmer room or a dust-blocked case removes that margin, and the enclosure needs venting. + +Its build predates the change that made the drop counters accumulate across a recovery (see [failure-handling.md](failure-handling.md)), and the output mode did not match the video's frame rate, so the zero-drops reading is the player's own counter under a mode mismatch. + +### Pre-flight rate check + +Before a long-running test, a script compares mpv's playback position with the wall clock. A 2560×1440 video at 60 fps, 45 Mbps, reads a steady ratio of 1.000, an overall ratio of 0.998 and zero on all three counters — dropped, decoder-dropped and late frames — against 0.954 for the cloud-textured 4K30 test video. Both fail the script's sub-check that playback settle within 20 s (42.1 s and 44.1 s), so that sub-check does not discriminate here. + +That file is HEVC Main profile at Level 5, High tier: High tier allows a bitstream buffer of roughly 100 Mbps at that level where Main tier allows 25, so the file's 45 Mbps [VBV](../glossary.md#vbv) cap sits well inside the limit. + +### Shorter runs + +A 75 s run of a 4K test video against a real DRM display covered about seven health-check ticks with no stall logged and no recovery started. Decode startup runs about 1–3 s behind the display check dex-wait-hdmi(1) performs. + +## Recovery and watchdog + +### Forced recovery on hardware + +Conditions: Raspberry Pi 4, the hardware path (`hwdec=drm`, `drmprime-overlay`, KMS plane), a 4K test file verified 0–89 with no decode failures before the run, recovery forced with `--test-rig-force-recovery-after-secs` under `--test-rig-no-sidecar`. Two runs, bounded at 100 s and 660 s. + +The recovery fired once about 15 s after the `loadfile` request in both runs. dexd absorbed the end-of-file event it causes, re-initialised demuxer and decoder without errors, and logged no fatal line. No second recovery fired on its own across 84 s in the first run and about ten minutes in the second, where the health check ticks every ten seconds. + +Processor use stayed at 25–27% with process time climbing between samples — what continuous realtime 4K decode costs on this Pi, against near 0% and flat time for a stopped event loop. The chip reported no throttling at 43.8–45 °C. The second run's heartbeat, 9 min 45 s after the recovery, carried the fields `loops=198 uptime=600s temp=45.2C frame-drops=0 vo-delayed=0 pos=584.0s pos-age=0s`. + +Not established: that the picture returned to the display. + +### Recovery check in CI + +The recovery test runs against a real mpv under software decode with no display. As shipped it passes, running its full 30 s deadline. Disabling the recovery counter's increment fails the recovery step in 3.11 s, logging an in-place recovery attempt immediately followed by the fatal playback-ended line. The lint and unit-test steps still pass under the same change: that code path is reachable only through a live mpv event loop. + +The check does not cover the hardware decode path, the overlay interop or the plane swap: a container with no GPU skips all three (see [ci.md](ci.md)). + +### Watchdog on the device + +Conditions: transient systemd units on a Raspberry Pi 4, running as the same unprivileged user as the packaged unit. + +| Setup | Result | +|---|---| +| `WatchdogSec=15` with the hang probe `--test-rig-hang-after-secs` | timeout at the limit, process killed with the abort signal, restarted by the unit's restart policy; the identical sequence again 18 s later | +| `WatchdogSec=8`, `ExecStartPre` sleeping 15 s | the timeout fired 8 s after the unit reached started, not 8 s after activation — `ExecStartPre` consumes none of the watchdog budget | +| `WatchdogSec=15`, healthy run with software HEVC decode, sampled every 8 s | the watchdog timestamp advanced every sample at the ping cadence, restarts 0, pings dropped 0 — a healthy run is not killed | + +The shipped unit sets `WatchdogSec=180` and dexd pings on its ten-second health-check tick, so eighteen pings fit each window and about seventeen consecutive drops are needed before systemd kills the process (derived). [service-unit.md](service-unit.md) and [failure-handling.md](failure-handling.md) describe the wiring. + +Nothing recovered during the twenty-five-hour run, so only tests exercise the counter that tracks overlapping recoveries. + +### Packaging checks on hardware + +The package installs, enables, starts, stops, removes and purges, with systemd resolving the unit from `/usr/lib/systemd/system/dexd.service`. The arm64 binary embeds no libyaml. `ldd` lists none and `strings` finds no libyaml C symbols — every YAML symbol is Rust-mangled — and no crate in the YAML parser's dependency subtree has a build script, a `links` key, or is a `-sys` crate. See [packaging.md](packaging.md). + +The test suite runs on a development workstation and on a Pi before a change lands; counts move with every change, so none is quoted here — [development.md](development.md) says how to run them. + +## Capture-instrument limits + +### 4K delivery rate + +4K delivery is about 27 fps, not 30. Measured over 600 frames in 22.43 s, scaling linearly from 300 frames in 11.04 s, so it is not startup skew. Inter-frame intervals cluster at 0.0358–0.0373 s with no doubled intervals, which is pacing; dropping would show 0.0333 s with occasional 0.0667 s. A different pixel format changes the timing not at all. + +The best-fitting explanation (assumed) is that the device transmits 4:2:2 over USB whatever is requested, two-thirds the data of 4:4:4. 4K30 then needs about 497 MB/s against USB 3.0 Gen 1's practical ceiling of about 450 MB/s, and 450/497 = 0.905 against a measured 27/30 = 0.90. + +The [capture deficit](../glossary.md#capture-deficit) is about 10% at 4K and belongs to the instrument, not the player; index analysis tolerates gaps by construction, so the deficit is a design input rather than a fault to chase. + +Two further readings show the same pacing. Loop points 3.000 s apart can be detected no later than one capture interval (0.038 s) after they occur, so no measured period should exceed 3.038 s; the measured minimum is 3.0510 s. The step histogram reads +1 ×1377, +2 ×171 (11.0%, matching 30/27) with 72 repeated indices. + +### 1080p60 delivery + +At 1080p60 the device captures everything: 1800 frames in 30.00 s, none undecodable, at 248 MB/s, which fits USB 3.0 where 4K30 does not. That is the configuration behind every frame-duration histogram above. + +### 4K60 capture + +Frame rates above 30 Hz at 4K cannot be measured through the device. It records 4K at 30 fps, so at 4K60 it captures every other frame: indices step by 2 throughout, every transition reads anomalous and loop detection breaks. + +Its EDID is HDMI 1.4 and caps at 2160p30, a TMDS character rate of 297 MHz; forcing 3840×2160@60 with `hdmi_enable_4kp60=1` leaves it at 297 MHz, because the driver will not synthesise a mode the sink does not advertise. 4K60 needs 594 MHz and an HDMI 2.0 sink, so 4K30 is the measured case and 4K60 is captured at 1080p60 as a weaker check. + +### Link speed + +A port can renegotiate from SuperSpeed down to USB 2.0 without notice — one did so twice within minutes. An undetected drop mid-run zeroes every captured frame and reads as total frame loss, so a run is valid only with the link at SuperSpeed, checked before and after. + +Enumerated at USB 2.0 — behind a hub that itself came up as a USB 2.0 device — the capture device stops advertising 4K input modes: it offers modes according to the bandwidth available. + +### Mode behaviour by sink + +The capture device lists 3840×2160@30 as its preferred detailed timing, and the vc4 driver builds no 3840×2160 mode from it unforced; forced, the identical timing works. A 2560×1440 monitor must not be given a forced display mode, because transmitting a mode the panel cannot show reads as a player fault. Both cases, and the integer-only refresh grammar that follows from mpv matching modes on [vrefresh](../glossary.md#vrefresh), belong to [exhibit-config.md](exhibit-config.md). + +## Not measured + +| Item | Status | +|---|---| +| 500 or more loop points at 4K30 | not tested | +| Colour and geometry (grey ramp, colour wheels, resolution wedges, border) | not automated; checked by eye against the source | +| A long run of the 45 Mbps 1440p60 video | not tested | +| An outside witness that the picture returns after a recovery | not tested | +| Two overlapping recoveries on a device | not tested | +| 4K60 on a Pi 5 | not tested | +| pivid as a player | not tested | +| The Pi 4's HEVC bitrate ceiling | not tested; encodes at 25.3, 39.7 and 45 Mbps all play at realtime, and the roughly 80 Mbps encode-target figure comes from outside this record | diff --git a/docs/design/packaging.md b/docs/design/packaging.md new file mode 100644 index 0000000..f68bac2 --- /dev/null +++ b/docs/design/packaging.md @@ -0,0 +1,167 @@ +# Packaging + +dexd is delivered as one Debian package, `dexd__arm64.deb`, built in CI and installed with apt. This page describes what the package contains and the rules a change to it must keep: install paths, the declared library floor, the crate policy, the toolchain floor, version and build identity, and the licence split. It is for a developer editing `Cargo.toml`, `deny.toml` or the files under `deploy/`. + +Terms are defined in [the glossary](../glossary.md). The workflow that builds and checks the package is described in [continuous integration](ci.md). + +## Delivery + +The player installs a built artifact and compiles nothing on the device. + +The package states the library requirement, so a device whose libmpv is too old for dexd fails at install, in front of whoever runs apt, instead of showing a black screen at the venue. The package also sets the install paths and creates the `dex` user and `/opt/dex`, so no install depends on the person who imaged the [dex card](../guides/build-player-card.md) remembering them. + +CI builds on an arm64 runner inside a `debian:trixie` container, so the binary links the same libmpv the devices carry; another distribution's libmpv would produce the mismatch the package exists to catch. + +The crate lives in the dex repository at `packages/dexd`, not as a submodule, so a change to the code, the packaging, CI and the dexOS image is one commit. + +Each build's .deb is copied to a device and installed from that file. An apt repository is planned; see the [roadmap](roadmap.md). + +## Contents + +The package installs three programs, their documentation and the service unit. + +| Path | Content | +|---|---| +| `/usr/bin/dexd` | the player, mode 755 | +| `/usr/bin/dex-exhibit-apply` | applies a changed exhibit config, mode 755 | +| `/usr/bin/dex-wait-hdmi` | waits for a connected display, mode 755 | +| `/usr/share/man/man1/` | `dexd.1`, `dex-exhibit-apply.1`, `dex-wait-hdmi.1` | +| `/usr/share/doc/dexd/` | `README.md`, the copyright file, the changelog | +| `/usr/share/lintian/overrides/dexd` | the override file and its reasons | +| `/usr/lib/systemd/system/dexd.service` | the unit, enabled at install and left stopped | + +Binaries go to `/usr/bin`, never `/usr/local/bin`: Debian policy reserves `/usr/local` for the local administrator. + +`dex-exhibit-apply(1)` ships because it runs on the device, as root, whenever the exhibit config changes. `dex-sidecar(1)` does not ship: it writes and checks a sidecar, and a video is prepared on a workstation. + +The package installs no video, no sidecar and no exhibit config, and creates no `/etc/dex`. All three are content that changes per installation and live together in `/opt/dex`, the mount point of the dex card's data partition; `postinst` creates that directory. A missing config makes dexd refuse to start and name the file to create — see [exhibit config](exhibit-config.md). + +The unit is enabled at install (`enable = true`) and left stopped (`start = false`). A device that is only power-cycled comes back playing, and a technician chooses the moment the player takes DRM master. [The systemd unit](service-unit.md) covers its settings. + +The release profile sets `opt-level = 2`, `lto = true` and `panic = "abort"`; the dev profile aborts on panic as well, so a panic behaves the same way under test. Cargo discovers `src/lib.rs` and `src/main.rs` on its own, so `Cargo.toml` carries no target sections, and `cargo test` ignores `panic = "abort"` for test builds. + +## Depends + +`Depends` has two halves: one derived from the built binary, one stated in `Cargo.toml`. + +```toml +depends = "$auto, libmpv2 (>= 0.40.0), adduser" +``` + +`$auto` runs dpkg-shlibdeps over the built binary, so the shared-library half of `Depends` follows the sonames the binary links and nobody writes it by hand. `ldd` reports 228 shared objects for the binary (measured); linking libmpv accounts for that count. dpkg-shlibdeps names only the packages providing the sonames the binary links itself, so a build resolves the derived half to a line such as `Depends: libc6 (>= 2.34), libmpv2 (>= 0.40.0)`. + +From symbols alone the floor is `libmpv2 (>= 0.19.0)`, the oldest libmpv exporting the symbols dexd calls. Two things dexd relies on are behaviour, which dpkg-shlibdeps cannot see: + +- the option `--gpu-hwdec-interop=drmprime-overlay`; +- the `END_FILE` event with `reason=stop` after a `loadfile replace`, which [in-place recovery](failure-handling.md) waits for. + +Both hold in libmpv 0.40, the version trixie ships. A device with 0.19 installs the package cleanly and then plays wrong, so the stated floor sits next to `$auto`. CI asserts the derived half and the stated floor separately, because deleting the stated floor still yields a package that builds and installs. + +Raise the floor whenever a fix is verified against a newer mpv. + +`Cargo.toml` declares `adduser` because `postinst` calls it, and lintian fails a maintainer script that uses a tool the package does not depend on. + +Installing the package pulls in more than these lines: libmpv2's own libraries, the two programs libmpv2 recommends (aria2 and yt-dlp), and systemd and dbus because a unit ships. + +## Crates + +`Cargo.toml` declares four crates, none of them a procedural macro, for about two dozen crates in the lock file. + +| Crate | What it does | +|---|---| +| `serde` | the map visitor only, with no `derive` feature; the sidecar grammar makes a duplicate key an error, which `serde_json`'s last-wins map cannot express | +| `serde_json` | parses the sidecar, including `\uXXXX` escapes and UTF-16 surrogate pairs | +| `sha2` | SHA-256 for the asset checksum | +| `yaml-rust2` | parses the `.yaml` exhibit config and errors on a duplicate mapping key | + +Add a crate only when it removes code dexd would otherwise carry, and only when its dependency tree is small enough to read. `Cargo.toml` declares `yaml-rust2` with `default-features = false`, which drops the `encoding` feature and `encoding_rs`, because the config is read with `fs::read_to_string` and so is UTF-8 already; that brings its measured cost to five added crates. The other YAML parsers weighed against it are in the alternatives table. + +The [watchdog](failure-handling.md) ping adds no crate and no entry to the derived `Depends`: sending `WATCHDOG=1` over an unbound datagram socket is `std::os::unix::net::UnixDatagram`, with `std::os::linux::net::SocketAddrExt` for an abstract-namespace name. + +## Dependency policy + +`deny.toml` makes the policy checkable: `cargo deny check` runs in CI and fails the build on a violation. + +- `[advisories]`: `yanked = "deny"` with an empty `ignore` list. An ignore entry records a judgement about the deployment that no later run rechecks. +- `[licenses]`: an allow-list (`Apache-2.0`, `MIT`, `MIT-0`, `Zlib`) trimmed to what the dependency graph contains, at `confidence-threshold = 0.9`. A dependency arriving under an unlisted licence fails the check and needs a one-line edit with a reason. `Zlib` was added for `foldhash`, reached through `yaml-rust2`, `hashlink` and `hashbrown`. Change this list in the same commit as the licence it records, so it cannot disagree with what ships. +- `[bans]`: `multiple-versions = "warn"`, since two versions of one crate are the first sign of a set outgrowing what a reader can audit; `wildcards = "deny"`; and `syn`, `quote`, `proc-macro2` and `serde_derive` denied by name, so turning on serde's `derive` feature fails the check. For a four-field struct, `#[derive(Deserialize)]` saves about fifteen lines of field extraction and costs the whole macro toolchain on the critical path of every package build. If a future dependency needs procedural macros, delete these entries and give the reason in the commit. +- `[sources]`: crates.io only; unknown registries and git dependencies are denied. A git dependency has no version and no audit trail, which does not suit software expected to run untouched for the length of an exhibition. + +Revisit the crate policy if a dependency's tree grows past what can be audited, or if the device stops being a viable build host. + +## Toolchain floor + +`rust-version = "1.85"` is the rustc Debian trixie ships, which is the compiler the devices have. + +A Raspberry Pi is also a development host, so the crate must build with that compiler; CI uses apt's rustc to enforce it. A dependency needing a newer compiler fails the CI build. Raise the floor only after confirming the devices can still build the package. + +CI installs cargo-deb from its 2.x line, because cargo-deb 3.7 uses let-chains and needs rustc 1.88 or newer. The pin follows from the toolchain floor; if the pin ever moves, the toolchain decision is what changed. + +## Version and build identity + +Two identifiers name a build: the package version apt compares, and the commit compiled into the binary. + +CI runs `cargo deb --deb-revision "+g"`, giving package versions of the shape `0.1.0-+g`. The run number leads because dpkg compares digit runs numerically, so ordering follows time. A commit-only revision orders by hash instead: `dpkg --compare-versions` sorts `0.1.0-1+gzz999999` above `0.1.0-1+g000aaaaa`, and apt then refuses a newer build as a downgrade. apt also skips an identical version without installing it, so a hand-built package of the same version needs `apt install --reinstall`. + +`deploy/changelog` is the Debian changelog: a non-native package without one is a lintian error, and it is where a technician reads what shipped. It records `0.1.0-1`, the first packaged release. + +`build.rs` writes the commit into the binary as `DEX_GIT_HASH`, using the standard library and no crates; dexd prints it to standard error as its first line. `--version` is not an option: `dexd --version 2>&1 | head -1` reads that line back, then prints the usage text and exits 2 — see [reference](../guides/reference.md). + +`build.rs` takes the first of three sources: + +1. `DEX_BUILD_ID`, an environment variable, trimmed to 12 characters; +2. `.dex-build-id`, a one-line file written by an external sync step and never committed; +3. `git rev-parse --short=12 HEAD`, with `+dirty` appended when `git status --porcelain` reports uncommitted changes. + +With none of them the build reports `nogit`, which leaves every device carrying that package unidentifiable. CI therefore sets `DEX_BUILD_ID` from the commit it is building and asserts that the packaged binary prints those 12 hex characters. + +CI sets the environment variable rather than writing `.dex-build-id`. A `rerun-if-changed` path that did not exist when the cached build ran counts as never changed, so a job that restores a `target/` cache and then writes the file gets a binary with the old identity. Cargo compares the value of a `rerun-if-env-changed` variable, which has no such hole. + +`build.rs` also emits `rerun-if-changed=src` and `=Cargo.toml`. Emitting any `rerun-if-changed` replaces Cargo's default rebuild-on-source-change, so without those two entries an edit without a commit would keep the previous hash. + +## Maintainer scripts + +`postinst` and `postrm` run as root on every device under `/bin/sh`, which is dash on Debian, so they contain no bashisms; `checkbashisms` runs in CI. + +`postinst` works inside a `case "$1" in configure)` block: it creates the `dex` system user, adds it to `video` and `render` where those groups exist, and creates `/opt/dex` at mode 0755. It then prints a notice, without failing the install, in three cases: `/opt/dex` contains no exhibit config; a config sits under `/etc/dex`; a systemd drop-in still overrides the display mode with `--mode`, a setting that moved into the [exhibit config](exhibit-config.md). `postrm` keeps the `dex` user and `/opt/dex`: the video is content the package never shipped, and the user may be named in something a technician wrote. + +[The systemd unit](service-unit.md) covers what each step is for. The package installs, starts, plays, stops, removes and purges on a Raspberry Pi (measured; see [the measurement record](measurements.md)). CI's lifecycle test repeats the install, remove and purge steps in a clean trixie container. + +## Lintian + +Lintian runs as `lintian --tag-display-limit 0 --fail-on error,warning`, so a finding fails the build. The package reports three tags; `deploy/lintian-overrides` silences them, with the reason for each, and ships inside the package. + +- `aliased-location`: cargo-deb 2.12 writes the unit to `lib/systemd/system`. On the merged-usr layout that trixie uses, that is `usr/lib/systemd/system`, the same directory through a symlink, and systemd resolves the unit there. Delete this override when the cargo-deb pin moves. +- `initial-upload-closes-no-bugs`: the tag wants the changelog to close an intent-to-package bug, a rule for packages uploaded into the Debian archive. The dexd package is built in CI and installed on the project's own devices. +- `embedded-library libyaml`, listed once per compiled binary: lintian tests for the string `did not find expected `, one of libyaml's error messages, which `yaml-rust2` reproduces because it is a Rust port of libyaml's scanner and parser. The arm64 binary embeds no libyaml (measured; see [the measurement record](measurements.md)). Re-check the override if the YAML dependency is ever swapped for one with a C backend. + +## Licensing + +The source, the packaging and the documentation are MIT-0; content (test cards, video masters, branding) is CC0-1.0; GPL appears only where it was inherited, through pi-gen into dexOS. The intent is to release the project's works to the public domain as far as is legally and practically possible, and both licences ask nothing of a reuser. + +The shipped .deb is a GPL-3+ combined work: it links Debian's libmpv, which links GPL-3+ libsmbclient. MIT-0 is GPL-compatible, so this source imposes no condition on anyone; the binary's terms follow from how a distributor builds mpv, and an mpv built without libsmbclient yields an LGPL-2.1+ combination. `LICENSE` states the source's terms and the binary's, and ships verbatim as the package's copyright file, so the distinction travels with the package. + +Per-file licensing is machine-readable through a single `REUSE.toml` following the REUSE specification, and `reuse --root . lint` runs in CI. One author under one licence makes a per-file header a restatement of the same fact in every file; per-file SPDX headers become the better choice the moment the crate mixes licences or takes third-party code. Repository-wide compliance waits on the GPL code inherited through pi-gen; annotating it would state a claim about someone else's licence rather than record one. + +## Alternatives + +| Option | Outcome | Why not | +|---|---|---| +| `/usr/local/bin` for the binaries | Installs and runs | Debian policy reserves `/usr/local` for the local administrator | +| The symbol-derived floor alone | `libmpv2 (>= 0.19.0)` | Installs on a device whose mpv lacks the behaviour dexd needs | +| A stock exhibit config as a dpkg-managed configuration file | dpkg preserves edits across upgrades | The config belongs beside the video, where the card shows it on any computer | +| A video inside the package | One file to install | Changing the video would mean rebuilding the software | +| serde with `derive` | About fifteen lines fewer | The syn, quote and proc-macro2 toolchain on every package build | +| `serde_yaml` | A YAML parser | Archived upstream | +| `serde_yaml_ng` | A YAML parser | Ten added crates | +| `saphyr` | A YAML parser | Twenty added crates and six procedural macros | +| JSON syntax only, no YAML parser | No added crate | The file a technician edits loses comments | +| An `sd-notify` crate or a libsystemd binding | About 150 lines fewer | A new shared-library link in `Depends`, and the ping policy stays here anyway | +| `.dex-build-id` as CI's build identity | One file to write | A path absent during the cached build never counts as changed | +| A commit-only package revision | A shorter version | Orders by hash, so apt can refuse a newer build | +| Hand-written unit enable and disable logic | Avoids the `aliased-location` tag | Reimplements cargo-deb's enable and disable handling, which runs on every install | +| Per-file SPDX headers | No `REUSE.toml` | The same fact restated in every file, above dense module docs | +| CC0-1.0 for code | One licence for everything | Fedora disallows CC0 for code, which forfeits a distribution channel | +| MIT, BSD-2-Clause | The same permissive intent | Require attribution | +| 0BSD, Unlicense | No attribution either | 0BSD is the same intent in different drafting; Unlicense is criticised as poorly drafted | +| GitHub Packages for distribution | One home for artifacts | It carries no Debian or apt repositories | diff --git a/docs/design/pi-capability.md b/docs/design/pi-capability.md new file mode 100644 index 0000000..a4145ce --- /dev/null +++ b/docs/design/pi-capability.md @@ -0,0 +1,333 @@ +# Raspberry Pi media capability + +This page records which Raspberry Pi hardware decodes and displays what, for a developer choosing a board or judging whether a proposed video will play. Vendor documentation, third-party reports and this project's own measurements stay apart; the unresolved items are listed at the end. + +## Evidence rules + +Every factual claim carries a source marker `[n]` into the list at the end, or a provenance label. A claim established by this project carries *measured* with the board named and is never merged into a vendor or community figure; [the measurement record](measurements.md) states the conditions. An unsourced or contradicted claim becomes an item on the measurement to-do list below, with the project's own test setup as the intended source (decided). + +Vendor product briefs exist for only three boards before the Pi 4: the Pi 1 Model B+, the Pi 3 Model B+ and the Zero 2 W [9][10][11]. Sources gives each brief cited for a capability row with its document number and publish date [10][11][12][13][14]. + +## Chips and boards + +Capability follows the chip, not the board name. + +| Chip | Boards | Video block | +|---|---|---| +| BCM2835 | Pi 1 A/A+/B/B+, Zero, Zero W, CM1 | VideoCore IV | +| BCM2836 | Pi 2 Model B (early) | VideoCore IV | +| BCM2837 | Pi 3 Model B (early), some Pi 2 Model B, CM3 | VideoCore IV at 400 MHz | +| BCM2837B0 | Pi 2 Model B (late), Pi 3 A+/B+, CM3+ | The BCM2837 silicon; clock and heat spreader differ | +| BCM2710A1, in the RP3A0 package | Zero 2 W | The BCM2837 die, repackaged | +| BCM2711 | Pi 4 Model B, Pi 400, CM4 | VideoCore VI plus a dedicated HEVC block | +| BCM2712 | Pi 5, Pi 500, Pi 500+, CM5 | VideoCore VII plus a same-family HEVC block; the H.264 block is unwired | + +Sources: the vendor processor pages [2][3][4][5][6][7][8], the HEVC device-tree node [19] and the absent H.264 block on BCM2712 [20]. The Zero 2 W's RP3A0 is a system-in-package holding the same silicon as BCM2837, which the commonly listed BCM2835/2836/2837(B0)/2711/2712 series omits [6]. + +Those pages state no video-block change from BCM2835 to BCM2837B0 — only the CPU cluster, clock and packaging differ — so this page treats the multimedia block as unchanged (derived) [2][3][4][5]. + +## Decode engines and APIs + +Two decode engines exist across the family, with no shared driver. + +- **The legacy VideoCore codec block.** A V4L2 stateful memory-to-memory driver, `bcm2835-codec`, on the MMAL firmware interface; BCM2835 through BCM2711, absent on BCM2712 [17]. +- **The Raspberry Pi HEVC block.** A V4L2 stateless driver, named in turn `rpivid`, `rpi-hevc-dec` and `hevc_d`; BCM2711 and BCM2712 [19][20][23][24][26]. + +The upstream `rpi-hevc-dec` patch series describes the decoder as "found in the BCM2711 and BCM2712 processors", so one driver covers both generations [22]. + +| Kernel branch | Chip | Node and compatible strings | +|---|---|---| +| `rpi-5.4.y` | BCM2711 | The original `rpivid` staging driver, merged there [26] | +| `rpi-6.12.y` | BCM2711 | `hevc_dec: codec@7eb10000`; `brcm,bcm2711-hevc-dec` and `raspberrypi,hevc-dec` [19] | +| `rpi-6.6.y` | BCM2712 | `raspberrypi,rpivid-vid-decoder`; `rpi-6.12.y` restructured that file and the equivalent node is not cited here (not tested) [20] | + +Three mutually incompatible decode APIs reach these engines. + +| API | Scope | Note | +|---|---|---| +| MMAL and OpenMAX IL | The proprietary VideoCore firmware interface, 32-bit only, BCM2835 through BCM2711 | OpenMAX is deprecated and unsupported on 64-bit kernels [28][35] | +| V4L2 stateful (memory-to-memory) | A standard Linux wrapper over MMAL, BCM2835 through BCM2711, H.264 and the older codecs [30] | The cited source gives no device-node number | +| V4L2 stateless (request API) | HEVC only, BCM2711 and BCM2712 [21][23][32] | Userspace parses the bitstream and submits slice parameters per frame | + +## Capability matrix + +| Video | BCM2835–2837B0, RP3A0 | BCM2711 | BCM2712 | +|---|---|---|---| +| H.264 1080p30 | Hardware [9][10][11] | Hardware [7][12] | Software; workable in practice [8][13][37][41] | +| H.264 1080p60 | Outside the hardware specification (level 4.0, 1080p30); some streams decode [9][10][31] | Hardware [7][12][15] | Software, roughly 50–60 % of the CPU [8] | +| H.264 3840×2160 | Fails; the driver clamps at 1920 px [17] | Fails on the same clamp [17] | Software; a vendor claim with no measurement [41] | +| HEVC 1080p | Software, marginal and thermally limited [46] | Hardware [7][12] | Hardware [8][13] | +| HEVC 3840×2160p30, 8-bit | Fails; no HEVC hardware [17][18] | Hardware; realtime on the zero-copy path alone (measured on a Raspberry Pi 4) [1] | Vendor claims 4Kp60 [8][13] | +| HEVC 3840×2160p60 | Fails [17][18] | 0.753× realtime (measured on a Raspberry Pi 4) [1]; an independent report gives 45–55 fps [45] | Vendor claims 4Kp60 [8][13] | +| MPEG-2, VC-1 | Declared by the legacy driver [17] | Declared by the legacy driver [17] | Software [8] | +| MPEG-4 part 2, H.263, MJPEG, JPEG | Hardware [17][10] | Hardware [17] | Software [8] | +| VP8, VP9, AV1 | No hardware decode; software is infeasible [17] | No hardware decode [17][21] | Software; VP9 at 1080p30 is easy, 4K30 generally fine, 4K60 drops frames [37] | + +The historical paid unlock for MPEG-2 and VC-1 decode is widely reported; no kernel source or vendor document states it, so this page does not assert it (not tested). + +### Dimension and format limits + +The legacy codec block decodes at most 1920 × 1920 px, fixed in the driver as `MAX_W_CODEC` and `MAX_H_CODEC` [17]. That clamp rules out 4K for every codec on that block, H.264 included, through to the Pi 4's legacy path. + +The same driver accepts H.264 profiles up to High and enumerates levels up to 5.1 [17], which the clamp supersedes. The hardware specification is level 4.0 and 1080p30, so 1080p60 streams may decode without being covered by it: "It can decode many 1080p60 streams, but you need to handle it efficiently", a Raspberry Pi engineer writes [31]. + +The HEVC decoder's largest frame is 4096 × 4096 px, set by the kernel constants `HEVC_D_MAX_WIDTH` and `HEVC_D_MAX_HEIGHT` [21][23]. The fifth revision of the `rpi-hevc-dec` patch series adds the tiled formats `NV12MT_COL128` and `NV12MT_10_COL128`, and its cover letter reports failures above that limit in `v4l2-compliance`, the V4L2 conformance test suite [25]. Luma and chroma must share one bit depth [23][25]. + +The bit-depth ceiling stays unresolved; three sources disagree. + +- The vendor line "H.265 (4Kp60 decode)" states no profile and no bit depth [7][12]. +- LibreELEC's table claims 8, 10 and 12 bits with high dynamic range [29]. +- The `rpi-6.12.y` driver source offers only `NV12_COL128`, its tiled 8- and 10-bit variants commented out of that list [21][22]. + +The BCM2711 peripherals datasheet, RP-008248-DS, is the likely source; this page does not cite it (not tested). + +## Pre-Pi-4 boards + +Nothing earlier than a Pi 4 is a candidate for a 4K artwork, and testing one is not worth the time (decided). Three independent lines of evidence agree. + +- Every pre-BCM2711 product brief tops out at 1080p30 H.264 and MPEG-4 decode and never mentions 4K or HEVC [9][10][11]. +- `bcm2835-codec` is the only hardware decode driver on those chips, and its 1920 px clamp applies [17]. HEVC is absent rather than limited: no entry in the driver's format table, and no HEVC node in `bcm2835.dtsi`, `bcm2836.dtsi`, `bcm2837.dtsi` or `bcm283x.dtsi` [18]. +- A Raspberry Pi engineer wrote of the Pi 3 Model B+ that "The HW only supports up to 1080p60 …". The same engineer wrote that a successful software decode would be moot, because "the HDMI output is realistically limited to 1080P60" [43]. Decode ceiling and output ceiling both apply. + +Software HEVC decode reaches only low-bitrate 1080p here. Community reports put a Pi 3 at roughly 11 Mbit/s of 1080p HEVC before it buffers, thermally limited; a second report gives roughly 15 Mbit/s at 100 % CPU [46]. LibreELEC caps the Pi 2 and Pi 3 at software standard definition in its 10.x releases, and a LibreELEC developer states that the optimised HEVC decode code from 9.2.x "was dropped in LE10 as it doesn't work with the new graphics stack (and, no, it won't come back)" [46]. + +No report of a 4K attempt on a Pi 0, Pi 1 or Pi 2 exists in this source set, so this page excludes them on the shared-silicon statements and the driver ceiling (assumed). + +Display output is a separate claim from decode: the Pi 4 brief lists "2 × micro HDMI (up to 4Kp60 supported)" under video output and "H.265 (4Kp60 decode)" under multimedia, in different rows [12]. The earlier boards fail on both counts [43]. + +## Pi 5 H.264 decode + +BCM2712 has no usable H.264 hardware decode or encode. + +- `bcm2712.dtsi` carries the comment `/* IOMMU2 for … HEVC; and (unused) H264 accelerators */`. The tree exposes no H.264 node, binding or register range; the accelerator's only other trace is an unused `h264` clock-name string in the power-domain node, its clock reference commented out [20]. +- A Raspberry Pi engineer states that BCM2712 has no H.264 hardware block for encoding or decoding [37]. +- `bcm2835-codec`, the only driver that ever provided H.264 decode, binds through the firmware platform device instead of device tree, and BCM2712 does not run that firmware stack [17]. +- The Pi 5 product brief has no multimedia row and never uses the string "H.264"; the Pi 500 brief lists only "H.265 (4Kp60 decode)" [13][14], where the earlier briefs all carry an explicit H.264 line [10][11][12]. + +BCM2711 also encodes H.264 at 1080p30 in hardware [7][12]. + +Official BCM2712 documentation leads with "4Kp60 HEVC hardware decode", follows with "Other CODECs run in software", and gives the CPU load of software H.264: roughly 10–20 % at 1080p24 and roughly 50–60 % at 1080p60 [8]. No vendor document frames the missing block as a regression; the absence is stated about BCM2712 alone [8][13][16]. + +A Raspberry Pi engineer says "The Pi5 can decode H.264 faster in software than the Pi4 can decode in hardware" [41]. That comparison carries no frame rate and no CPU figure, and no independent benchmark exists (not tested); the vendor's 50–60 % of the CPU at 1080p60 is the usable figure [8]. + +A third-party report measures the HEVC case on the same board: CPU use falls from roughly 45 % to roughly 20 % when an H.265 stream moves to `-hwaccel drm` on `/dev/video19`; no H.264 hardware transcode is available [47]. + +Two actions follow (decided). Measure before deploying an H.264 artwork on a Pi 5. Author anything new in HEVC, the one codec with a hardware path on both BCM2711 and BCM2712; a master transcoded to HEVC during [preparation](../guides/prepare-video.md) uses the Pi 5 decoder. + +## Players and paths + +| Player | BCM2835–2837B0 | BCM2711 | BCM2712 | +|---|---|---|---| +| `hello_video` (OpenMAX IL) | Works as a raw H.264 demo [27] | Works on the 32-bit legacy stack alone [28] | Fails; OpenMAX is unsupported on 64-bit kernels [35] | +| VLC | H.264 through V4L2 stateful [35] | One community report has HEVC hardware decode on bullseye (Debian 11), "presumably" bookworm (Debian 12) [38] | No direct evidence either way [40] | +| Kodi (v18 on MMAL and OpenMAX, v19 and later on V4L2) | Pi 1 has no HEVC; Pi 2 and Pi 3 are software and standard-definition in LibreELEC 10.x [29] | Hardware HEVC to 4K at 8, 10 and 12 bits, LibreELEC 10.x or later [29] | The same, LibreELEC 11.x or later [29] | +| ffmpeg | `h264_mmal` exists; one report has it failing with "Did not get output frame from MMAL" [51] | HEVC through V4L2 stateless in the OS-supplied build [38]; `hevc_v4l2m2m` implements the stateful API alone and cannot drive it [33] | HEVC through `-hwaccel drm` on `/dev/video19` [47] | +| mpv | H.264 through `v4l2m2m` expected, unverified by this project | The working realtime 4K30 HEVC path (measured on a Raspberry Pi 4) [1]; a third party observed `v4l2m2m` working [49] | HEVC works; frame drops are reported when the output path detiles [42] | +| dexd | Does not run | The realtime 4K30 configuration (measured on a Raspberry Pi 4) [1] | Not tested | + +dexd's decode path is ffmpeg's V4L2-request HEVC decoder to DRM PRIME to a KMS plane, selected through mpv's `--gpu-hwdec-interop=drmprime-overlay` [1]; [Architecture](architecture.md) describes the stack. + +One forum thread covers VLC on a Pi 5 [40]. In it, a reporter's working fallback was a custom player modelled on `hello_drmprime` [52], and the original poster's problem traced to UDP streaming. Treat VLC's Pi 5 decode path as unknown. + +## Frame layout + +On BCM2711 the HEVC decoder emits SAND-tiled NV12, which the display block scans out natively [21]. A display path that does not accept that layout detiles it first, and detiling costs most of the frame rate (measured on a Raspberry Pi 4) [1]. This project's own measurement and two third-party reports show frames lost that way. + +- On a Pi 4 at 3840×2160, 30 fps: 14.3 fps for the CPU copy and about 5 fps through GL, against 29.1 fps on the zero-copy path [1]. +- On a Pi 5 running trixie, mpv logged `VO: [gpu] 1920x1080 yuv420p`, converting away from the tiled buffer, and dropped frames in every output mode [42]: + - 1080p60 output: 7 frames. + - 4K60 output: 176 frames. + - 3440×1440, over roughly 25-second clips: 500–760 frames. +- On a Pi 4 under Wayland, `--hwdec=v4l2m2m` produced a blue screen because Mesa could not import the format; `v4l2m2m-copy` worked at about 80 % of the CPU, with drops [49]. + +DRM allows a single authenticated master, and a Raspberry Pi engineer warns against running two independent processes to create overlays [34], so dexd uses mpv's native DRM output with no display server running. VLC, started fullscreen from X, borrows X's planes through DRM leases for zero-copy scanout [34]. Whether mpv can do the same is untested by this project. + +dexd swaps mpv's plane defaults to keep the 4K video off the 3D render path; see [Architecture](architecture.md). + +## Stateless decoder setup + +On Raspberry Pi OS the stateless HEVC decoder may need `dtoverlay=rpivid-v4l2` in `/boot/firmware/config.txt`; the default has changed between releases, and LibreELEC enables it [30]. Adding the line takes effect after a reboot. These commands confirm the decoder either way: + +``` +sudo apt install v4l-utils +ls /dev/video* +v4l2-ctl --list-devices +``` + +`v4l2-ctl` lists each decoder under its driver name with its `/dev/videoN` nodes beneath; a `rpivid`, `rpi-hevc-dec` or `hevc_d` entry is a match. On the Raspberry Pi 4 used for the measurements that node is `/dev/video19` [1]. + +Without the overlay, ffmpeg fails with `No device available for decoder: device type drm needed for codec hevc` [50]. mpv instead falls back to software decoding and drops 4K frames, so dexd sets `hwdec-software-fallback=no`, which treats the fallback as an error. + +ffmpeg needs `--enable-v4l2-request --enable-libdrm --enable-vout-drm` at build time for the arm64 V4L2-request path [50]. Raspberry Pi ships a downstream ffmpeg fork carrying those patches; upstream ffmpeg lacked full stateless support at the driver's kernel submission, and any later merge is unverified [23][33]. The fourth revision of the `rpi-hevc-dec` patch series reports 142 of 147 `v4l2-compliance` tests passing on kernel 6.16.0 [23]. + +## Pi 4 throughput + +Measured on the Raspberry Pi 4 described in [the measurement record](measurements.md) [1]. + +Decode throughput on BCM2711 tracks megapixels per second, not the resolution label; 4K30 on this page is 3840×2160 at 30 fps. About 250 Mpix/s is the practical budget on the shipped zero-copy path, and a video above it will not play at its frame rate. ffmpeg's direct-to-DRM output goes higher, and dexd does not use it. + +| Mode and path | Result | +|---|---| +| 4K30 HEVC, 249 Mpix/s, decode only | 1.36× realtime, roughly 41 fps | +| 4K40 HEVC, decode only | 1.08× realtime | +| 4K60 HEVC, 498 Mpix/s, decode only | 0.753× realtime, roughly 45 fps | +| 4K30 HEVC through mpv's zero-copy path | 0.969× realtime, zero dropped frames, about 35 % decode headroom | +| 4K40 HEVC through ffmpeg's direct-to-DRM output (see [vout_drm](../glossary.md#vout_drm)), not a dexd path | 1.70× realtime | +| 4K60 HEVC through mpv's zero-copy path | 0.976× realtime with 83 dropped frames | + +At 4K60 the player stays close to realtime by discarding frames, which is why [the pass criteria](measurements.md) require a drop count beside the ratio. Only the display path blocks 4K40: the capture device used for the measurements advertises an HDMI 1.4 EDID, so it offers no mode above 2160p30. + +The vendor specification for BCM2711 says "H.265 (4Kp60 decode)" [7][12][15]. That figure plausibly describes the decode block in isolation, and a complete decode-and-present pipeline does not reach it: 0.753× realtime on a Raspberry Pi 4 [1]. An independent Kodi user reports 45 to 55 fps on a real 4K60 file, dropping to 25 fps, in a thread that reaches no resolution [45]. Do not plan a 4K60 artwork on a Pi 4. + +### Display output + +4K30 runs at a 297 MHz pixel clock, the HDMI 1.4 ceiling, so `hdmi_enable_4kp60` does nothing for it [1]. 4K60 needs 594 MHz and an HDMI 2.0 sink. On a Pi 4 it also needs `hdmi_enable_4kp60=1`, the first micro-HDMI port and one connected display, since driving both ports caps the board at 4K30 [1]. + +The vc4 driver builds modes from what the sink advertises and refuses one it does not. Two attempts on the capture device left the pixel clock at 297 MHz [1]: + +- a forced mode, `video=HDMI-A-1:3840x2160@60` with `hdmi_enable_4kp60=1`; +- a request for a generated mode, `video=HDMI-A-1:3840x2160M@40`. + +Lowering the frame rate does not lower the bandwidth at 4K, because the HDMI timing standard, CEA-861, gives 2160p24, 2160p25 and 2160p30 one 297 MHz clock and varies the horizontal blanking alone: total widths 4400, 5280 and 5500 px [1]. The mode negotiated for the measurements is 3840×2160, RGB 4:4:4, 8 bits per component, limited range, at that 297 MHz pixel clock [1]. + +### Bitrate + +This project has measured no bitrate ceiling. One community sweep on a Pi 4 running LibreELEC reports 4K30 HEVC playback by bitrate [44]: + +| Bitrate | Playback | +|---|---| +| to about 60 Mbit/s | smooth | +| around 70 Mbit/s | frames drop | +| above 80 Mbit/s | choppy on real-world rips | +| above 120–130 Mbit/s | a hard freeze | + +The CPU stayed under 16 % throughout, and the sweep's author attributed the limit to HEVC intermediate memory and memory bandwidth [44]. It has not been reproduced; treat it as an order of magnitude. The project's own encodes played at realtime run at 25.3, 39.7 and 45 Mbit/s [1]. + +### Thermal limits + +A Pi 4 soft-throttles at 80 °C, and throttling presents as intermittent frame drops [1]. Idle at 4K30 with no active cooling, the board reads 42.3 °C with no throttle bits set [1]. In a sealed passive case with no fan, playing a 2560×1440p60 video, the board holds about 77 °C, peaks at 78.4 °C and does not throttle over 2 hours 4 minutes — 1.6 °C under the limit [1]. A warmer room, a dusty enclosure or direct sun removes that margin, so an enclosure needs venting, a heatsink or a quiet fan (decided). + +### Memory + +The board reserves 512 MB of CMA for decoder frame buffers, unavailable to the player [1]. The player adds about 190 MB: a 123 MB video gave 314 MB of resident memory [1]. On a 1 GB board that leaves roughly 170 MB for the video: 1024 − 512 (CMA) − about 150 (kernel and userland) − 190 (overhead) [1]. + +Every figure comes from a 4 GB board: [the twenty-five-hour run](measurements.md) showed no leak but says nothing about fit on a smaller board, so dexd's first release limits supported video size (decided). + +## Pi 5 status + +Everything about BCM2712 here is vendor- or community-sourced; this project has taken no Pi 5 measurements, and a Pi 4 result does not transfer to a Pi 5 (decided). + +A Pi 5 almost certainly decodes HEVC in hardware (assumed); the presentation path is unknown. Expect the failure already seen on a Pi 4: throughput looks realtime while each frame takes the slower, non-zero-copy route [1]. The mpv output and hardware-decode settings depend on what a Pi 5's mpv reports, so the Pi 5 test chooses them there. + +Raspberry Pi's patched ffmpeg builds are suffixed `+rpt1` and `+rpt2`; trixie ships `+rpt1`, and whether a Pi 5 needs the `+rpt2` HEVC patches is unresolved. Settle it before a Pi 5 measurement. + +## Legacy-stack playback + +Gapless hardware playback on a Pi without dexd exists on Raspberry Pi OS buster alone, 32-bit, at 1080p30 (measured on the project's own buster image). It runs on `hello_video`, which needs the legacy Broadcom graphics stack that later releases dropped. `hello_video` does not start on bullseye, where `libbrcmGLESv2.so` is absent: `hello_video.bin: error while loading shared libraries: libbrcmGLESv2.so: cannot open shared object file` (measured). + +Pinning an image to buster pins it to a 2019 Debian that can never be security-updated. Such an image suits an offline video installation and rules out anything network-exposed, a player with a web interface included. + +A buster image boots on the Pi 4, the Zero 2 W and the original Zero, and not on the Pi 5. On the Pi 1 Model B it does not boot either, for reasons unresolved (measured on the project's buster image); Raspberry Pi Imager offers that board bullseye 32-bit as its newest release. + +dexd needs a Pi 4 or later, so the cheaper boards drop out: about 18 Swiss francs for a Zero W and about 20 for a Zero 2 W, against about 66 for a Pi 5 (prices compared in 2024; assumed, no price source recorded). + +## Board choice + +| Board | Outcome | +|---|---| +| Pi 0, 1, 2, 3, Zero 2 W | 1080p H.264: the 1920 px clamp, no HEVC hardware, HDMI output limited to 1080p60 | +| Pi 4, 400, CM4 | 4K30 HEVC on the zero-copy path; 4K60 out of reach | +| Pi 5, 500, CM5 | HEVC in hardware, every other codec on the CPU; the display path unmeasured by this project | + +## Measurement to-do + +Each item below is unsourced or contradicted in the public record, and this page asserts none of them; the project's own test setup is the intended source. + +1. Whether and when MMAL and OpenMAX were removed from Raspberry Pi OS, and whether `hello_video` still runs on bookworm; the bookworm release thread in this source set states no removal point [36]. +2. The device node the legacy stateful decoder appears on. +3. Whether `h264_mmal` requires a `gpu_mem` firmware split (see [CMA](../glossary.md#cma)), and whether its output path copies. +4. Any H.264 level above 4.0 on pre-BCM2711 chips. +5. mpv's `v4l2m2m` path on pre-BCM2711 chips; the mpv documentation mirror in this source set carries no Raspberry Pi content [48]. +6. Whether mpv can run a zero-copy KMS path under X11. +7. The HEVC bit-depth ceiling on BCM2711. +8. Whether ffplay supports the zero-copy KMS path on a Pi. +9. Upstream ffmpeg's V4L2-request merge status. +10. VLC's HEVC decode path on a Pi 5. +11. One Pi 5 forum thread reports the decoder advertising 1920 × 1088 while also claiming 4Kp60; the thread does not resolve it [39]. +12. Capability rows for CM4, CM5 and the Pi 400, inferred here from shared-chip statements rather than their own briefs [7][8]. +13. Per-model briefs for the Pi 2 Model B, Zero, Zero W, CM1, CM3, CM3+, the original Pi 3 Model B and the Pi 3 Model A+; the Pi 1 Model B+ brief is cited from a third-party mirror, since the vendor now serves only its mechanical drawings. +14. No public BCM2712 datasheet was located; the decoder block is Raspberry Pi's own design, so one may not exist. +15. An independent benchmark of Pi 4 H.264 hardware against Pi 5 H.264 software on one clip. +16. A direct 4K attempt on a Pi 0, Pi 1 or Pi 2. +17. A citation for the historical licence-key unlock of MPEG-2 and VC-1. + +## Sources + +Each entry was read from the fetched page, saved file or source file. + +**This project's measurements** + +1. dex project measurements, Raspberry Pi 4 and BCM2711, Debian trixie — see [the measurement record](measurements.md). + +**Vendor documentation** + +2. Raspberry Pi Documentation, Processors: BCM2835. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/bcm2835.adoc` +3. Raspberry Pi Documentation, Processors: BCM2836. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/bcm2836.adoc` +4. Raspberry Pi Documentation, Processors: BCM2837. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/bcm2837.adoc` +5. Raspberry Pi Documentation, Processors: BCM2837B0. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/bcm2837b0.adoc` +6. Raspberry Pi Documentation, Processors: RP3A0. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/rp3a0.adoc` +7. Raspberry Pi Documentation, Processors: BCM2711. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/bcm2711.adoc` +8. Raspberry Pi Documentation, Processors: BCM2712. `https://github.com/raspberrypi/documentation/blob/master/documentation/asciidoc/computers/processors/bcm2712.adoc` +9. Raspberry Pi Model B+ product brief, from a third-party mirror; the vendor no longer serves the original. `https://cdn-shop.adafruit.com/datasheets/pi-specs.pdf` +10. Raspberry Pi 3 Model B+ product brief, RP-008338-DS-2, published October 2025. `https://pip-assets.raspberrypi.com/categories/532-raspberry-pi-3-model-b/documents/RP-008338-DS-2-raspberry-pi-3-b-plus-product-brief.pdf` +11. Raspberry Pi Zero 2 W product brief, RP-008359-DS-1, published April 2024. `https://pip-assets.raspberrypi.com/categories/584-raspberry-pi-zero-2-w/documents/RP-008359-DS-1-raspberry-pi-zero-2-w-product-brief.pdf` +12. Raspberry Pi 4 Model B product brief, RP-008344-DS-5, published April 2026. `https://pip-assets.raspberrypi.com/categories/545-raspberry-pi-4-model-b/documents/RP-008344-DS-5-raspberry-pi-4-product-brief.pdf` +13. Raspberry Pi 5 product brief, RP-008348-DS-6, published April 2026. `https://pip-assets.raspberrypi.com/categories/892-raspberry-pi-5/documents/RP-008348-DS-6-raspberry-pi-5-product-brief.pdf` +14. Raspberry Pi 500 product brief, RP-008349-DS-4, published April 2026. `https://pip-assets.raspberrypi.com/categories/1115-raspberry-pi-500/documents/RP-008349-DS-4-raspberry-pi-500-product-brief.pdf` +15. Raspberry Pi 4 Model B specifications page, read from an Internet Archive snapshot. `https://www.raspberrypi.com/products/raspberry-pi-4-model-b/specifications/` +16. "Introducing: Raspberry Pi 5!", vendor news post. The line about removed H.264 hardware decoding in its comments is a reader's question, and no vendor statement. `https://www.raspberrypi.com/news/introducing-raspberry-pi-5/` + +**Kernel and driver source** + +17. `drivers/staging/vc04_services/bcm2835-codec/bcm2835-v4l2-codec.c`, raspberrypi/linux `rpi-6.6.y`. `https://github.com/raspberrypi/linux/blob/rpi-6.6.y/drivers/staging/vc04_services/bcm2835-codec/bcm2835-v4l2-codec.c` +18. `arch/arm/boot/dts/broadcom/` (`bcm2835.dtsi`, `bcm2836.dtsi`, `bcm2837.dtsi`, `bcm283x.dtsi`), raspberrypi/linux `rpi-6.6.y`. `https://github.com/raspberrypi/linux/tree/rpi-6.6.y/arch/arm/boot/dts/broadcom` +19. `bcm2711.dtsi`, raspberrypi/linux `rpi-6.12.y`. `https://github.com/raspberrypi/linux/blob/rpi-6.12.y/arch/arm/boot/dts/broadcom/bcm2711.dtsi` +20. `bcm2712.dtsi`, raspberrypi/linux `rpi-6.6.y`. `https://github.com/raspberrypi/linux/blob/rpi-6.6.y/arch/arm64/boot/dts/broadcom/bcm2712.dtsi` +21. `drivers/media/platform/raspberrypi/hevc_dec/hevc_d_video.c`, raspberrypi/linux `rpi-6.12.y`. `https://github.com/raspberrypi/linux/blob/rpi-6.12.y/drivers/media/platform/raspberrypi/hevc_dec/hevc_d_video.c` +22. `rpi-hevc-dec` upstream patch series v1, patchew archive. `https://patchew.org/linux/20241220-media-rpi-hevc-dec-v1-0-0ebcc04ed42e@raspberrypi.com/20241220-media-rpi-hevc-dec-v1-4-0ebcc04ed42e@raspberrypi.com/` +23. LWN.net mirror of the v4 patch cover letter. `https://lwn.net/Articles/1028029/` +24. "Raspberry Pi HEVC Decoder Driver Posted For Linux Kernel Review", Phoronix: one stateless V4L2 driver covering BCM2711 and BCM2712. `https://www.phoronix.com/news/Raspberry-Pi-HEVC-H265-Decode` +25. LWN.net mirror of the v5 patch cover letter. `https://lwn.net/Articles/1060711/` +26. "V4L2 HEVC driver", raspberrypi/linux pull request 3505, merged to `rpi-5.4.y`. `https://github.com/raspberrypi/linux/pull/3505/files` +27. `hello_video` source, raspberrypi/userland. `https://github.com/raspberrypi/userland/blob/master/host_applications/linux/apps/hello_pi/hello_video/video.c` +28. popcornmix/omxplayer project documentation. `https://github.com/popcornmix/omxplayer/blob/master/README.md` +29. LibreELEC documentation, Raspberry Pi hardware. `https://github.com/LibreELEC/documentation/blob/master/hardware/raspberry-pi.md` + +**Raspberry Pi forums** + +30. "All about accelerated video on the Raspberry Pi". `https://forums.raspberrypi.com/viewtopic.php?t=317511` +31. "H264 video decoding": a Raspberry Pi engineer on the level 4.0 specification. `https://forums.raspberrypi.com/viewtopic.php?t=298607` +32. "Should v4L2 enable HW decode of hevc/h265?". `https://forums.raspberrypi.com/viewtopic.php?t=296736` +33. "FFmpeg hardware acceleration for transcoding": a Raspberry Pi engineer on the downstream fork and on ffmpeg's stateful `*_v4l2m2m` decoders. `https://forums.raspberrypi.com/viewtopic.php?t=331026` +34. "Pi 4 h265 decoding display with overlay": the single-master constraint, and a Raspberry Pi engineer on DRM leases. `https://forums.raspberrypi.com/viewtopic.php?t=332651` +35. "OMXPlayer — why it's no longer there?": a Raspberry Pi engineer on OpenMAX, and "The underlying platform is V4L2." `https://forums.raspberrypi.com/viewtopic.php?t=346146` +36. "A little bit on RPiOS 'Bookworm'": 98 posts; it discusses no MMAL or OpenMAX removal. `https://forums.raspberrypi.com/viewtopic.php?t=352477` +37. "RPi5 Codec confusion": a Raspberry Pi engineer on the absent H.264 block, with community notes on VP9. `https://forums.raspberrypi.com/viewtopic.php?t=357870` +38. "HEVC Support on RPi 4/5": a community report on a Pi 4 under bullseye. `https://forums.raspberrypi.com/viewtopic.php?t=377567` +39. "Decoding H265 on Raspberry Pi 5 via V4L2": internally inconsistent on 1920 × 1088 against 4Kp60. `https://forums.raspberrypi.com/viewtopic.php?t=381601` +40. "Pi 5, VLC, HEVC playback": one community report. `https://forums.raspberrypi.com/viewtopic.php?t=390492` +41. "Raspberry Pi 5 and the Lack of Hardware H.264 Decoding": two Raspberry Pi engineers; the 13 posts give no frame rate and no CPU figure. `https://forums.raspberrypi.com/viewtopic.php?t=391283` +42. "Pi 5: mpv dropping frames": trixie and mpv, drop counts per output mode. `https://forums.raspberrypi.com/viewtopic.php?t=393942` +43. "4K videos on Rasp Pi 3 B+": Raspberry Pi engineers on the decode and output ceilings. `https://forums.raspberrypi.com/viewtopic.php?t=223801` + +**Independent measurements and third-party reports** + +44. "Pi 4 HEVC playback max bitrate", LibreELEC Forum: a single-user bitrate sweep, never independently reproduced. `https://forum.libreelec.tv/thread/22076-rpi4-hevc-playback-max-bitrate/` +45. "Raspberry Pi 4B not reaching 4K 60 fps when playing an HEVC movie", LibreELEC Forum: 45 to 55 fps, unresolved. `https://forum.libreelec.tv/thread/24403-raspberry-pi-4b-not-reaching-4k-60fps-when-playing-hevc-hdr-movie-9-97-1-fresh-i/` +46. "HEVC / 265 files on a Pi 3B, around 11Mbit, it can't keep up", LibreELEC Forum. `https://forum.libreelec.tv/thread/17577-hevc-265-files-on-a-pi-3b-around-11mbit-it-can-t-keep-up/` +47. "Raspberry Pi 5 H265 HEVC hardware decoding working", Frigate discussion. `https://github.com/blakeblackshear/frigate/discussions/18431` +48. "Hardware Decoding", a third-party mirror of mpv documentation, with no Raspberry Pi content. `https://mpv-player-mpv.mintlify.app/av/hardware-decoding` +49. mpv issue 10956: Pi 4 under Wayland. `https://github.com/mpv-player/mpv/issues/10956` +50. jellyfin-ffmpeg issue 129: the Pi 4 64-bit V4L2-request setup. `https://github.com/jellyfin/jellyfin-ffmpeg/issues/129` +51. "Using h264_mmal decoder on Raspberry Pi 4", ffmpeg-user list: an unresolved failure report. `https://www.mail-archive.com/ffmpeg-user@ffmpeg.org/msg23170.html` +52. jc-kynesim/hello_drmprime: a minimal DRM PRIME to KMS zero-copy reference. `https://github.com/jc-kynesim/hello_drmprime` diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md new file mode 100644 index 0000000..7ee9d36 --- /dev/null +++ b/docs/design/roadmap.md @@ -0,0 +1,139 @@ +# Roadmap and open questions + +This page is for a developer choosing where to contribute: what dexd is planned to grow into, what has been ruled out, and what still needs a decision. Nothing here is implemented unless the text says so; the other pages under `docs/design/` describe current behaviour. + +## Origin + +dex plays video artworks on Raspberry Pi players in galleries. Its proven gapless loop was hello_video, which needs the legacy Broadcom graphics stack, which ships only on buster and caps output at 1080p. + +Output at 3840×2160 is a requirement (decided), and resolution alone disqualifies buster. Players are not networked, so missing security updates cost nothing, and buster's inability to run on a Raspberry Pi 5 is a hardware-purchasing question, not a reason for the move. + +No published work offered a gapless 4K loop on the current Raspberry Pi graphics stack, so dexd was written. It feeds libmpv an endless byte stream, so the decoder never reaches the end of the file and never seeks (see [endless-stream.md](endless-stream.md)). + +Two further requirements are decided. The player must survive having mains power cut, which is how a gallery switches it off. The delivery format of a master is not a constraint: a master — the artist's original file — arrives in whatever format the artist has, and the project converts it before it reaches the player (see [../guides/prepare-video.md](../guides/prepare-video.md)). + +## Stages + +| Stage | State | +|---|---| +| Gapless 4K playback: 3840×2160 at 30 fps, repeating with no visible pause | Built and packaged | +| A pi_video_looper backend for dexd | Decided, not started | +| Ingest on the player: transcode during USB copy | Decided, not started | +| An exhibition format: playlist, per-video timing, transforms | Decided, not started | + +See [exhibit-config.md](exhibit-config.md) and [packaging.md](packaging.md) for what the first stage consists of; the other three are not specified in detail. + +## Player integration + +pi_video_looper loads a player backend by module name, so a backend is one Python file. + +dexd's backend goes into a fork of the upstream project: a new backend file patches nothing upstream. The fork stays GPLv2. + +Once the fork is stable, the project opens an issue asking upstream whether a pull request would be welcome; no dex release waits for the reply. + +Which process starts and stops playback is open. The looper's interface assumes a player it starts and stops once per video, while dexd runs until it is killed. + +Other options: + +- The looper spawns dexd and stops it with a termination signal — the current answer: it matches the other backends and fits one artwork per exhibition. The looper must not restart dexd on exit, which would fight dexd's endless stream. +- dexd stays up and takes new videos over a control channel — needs an interface dexd does not have, and is worth building only once playlists with gapless transitions are required. +- dexd absorbs USB copy, transcoding and playlists — then no looper is left to integrate with. + +## Ingest on the player + +A technician prepares the video on a workstation and copies it to the dex card. The video, its sidecar and the exhibit config sit together in `/opt/dex`, the card's FAT data partition, which any computer mounts (see [data partition](../glossary.md#data-partition)). + +The planned stage moves preparation onto the player: during USB copy, the player converts with ffmpeg every file on the stick that has no converted counterpart yet. + +That step normalises GOP structure and keyframe placement, which decides whether a file can loop gaplessly. It also records the frame rate, because an elementary stream carries no timestamps (see [sidecar.md](sidecar.md)). + +## Exhibition format + +The playlist stage plays several videos, each with its own timing and transforms such as rotation and mirroring. Both transforms are cheap in mpv (`--video-rotate`, `--vf=hflip`) and cheaper on a KMS plane. The sidecar's flat JSON shape is a plausible starting point. This stage changes which process starts and stops playback, so the answer under [Player integration](#player-integration) should not foreclose it. + +## Operating system images + +buster stays as the legacy line for existing 1080p players; a trixie line carries 4K with dexd. dexd needs trixie for four things, all of which arrived after buster: + +- the Raspberry Pi HEVC decoder driver (see [rpivid](../glossary.md#rpivid)) +- the V4L2 stateless request interface +- libmpv 0.40 +- trixie's Raspberry Pi-patched ffmpeg + +The cost is two bases to maintain, and the legacy line keeps the limitation stated under [Origin](#origin). + +The first shipped artwork card is plain Debian trixie with the package. Porting the dexOS patch series to pi-gen's trixie branch is the largest unknown in the image work. Upstream projects stay patched with quilt, so the patch series reads as the list of changes. + +## Boot and device work + +Boot time is the interval between mains power and the first frame on the wall. With no graceful shutdown, every power cycle is a cold boot in front of an audience (see [service-unit.md](service-unit.md)). The work is planned after 1.0, because it shortens a path that already works. + +The method is fixed: + +- Measure with `systemd-analyze blame` or `systemd-analyze critical-chain` before changing anything. +- For each cost, ask whether this image needs the thing at all: a card runs one player against one display, so much of what a general-purpose Raspberry Pi OS starts has no consumer. Candidates: the Bluetooth service, swap (`dphys-swapfile`), the wait for a network address. +- Add mount and check options to `cmdline.txt`: `noatime`, `nodiratime`, `data=writeback`, `fsck.repair=yes`. +- Fold the answers into the image build: a service that is never installed cannot cost boot time or come back on an update. + +Reordering must not start dexd before something it depends on and rely on restarts to hide the missing dependency. `StartLimitIntervalSec=0` and the restart policy stay as they are. + +Boot output stays on screen: a player restarting in front of visitors should show why, so `quiet` stays off the kernel command line. A shutdown button on a general-purpose input pin is planned and not built. + +## Failure escalation + +dexd recovers in place, and systemd restarts the process when it exits or stops answering the watchdog (see [failure-handling.md](failure-handling.md)). + +Reboot escalation is decided and not built: a second systemd unit with a burst counter reached through `OnFailure=` reboots the device after repeated failures inside a window. `StartLimitAction=reboot` is not used, because it interacts badly with `StartLimitIntervalSec=0`, which is what makes dexd retry forever. + +## On-site fault signal + +The on-site fault signal is decided and not built. Refusals go to the system log only, so at the venue every fault looks the same: a black screen. The signal makes a refusal visible without a laptop. + +A privileged `ExecStopPost=` line in the unit will write the last refusal to `/dev/tty1`, because the text console has DRM master back exactly when dexd has refused (see [DRM master](../glossary.md#drm-master)). The escape sequence and which process has the display depend on the kernel and the hardware, so the line is not added until it has been checked on a Raspberry Pi with a projector attached. The troubleshooting guide carries a `journalctl -u dexd -n 20` line in the meantime (see [../guides/run-check-troubleshoot.md](../guides/run-check-troubleshoot.md)). + +## Distribution + +An apt repository on GitHub Pages will serve the package, so a device runs `apt update && apt install dexd` and upgrades work; GitHub Packages carries no Debian format. + +The documentation goes to `dex.ars.is/docs`, built with Starlight, a documentation-site generator for Astro, so it deploys with the site already on that domain and needs no subdomain of its own. + +Neither exists yet: the package changelog records `0.1.0-1`, and no git tag has been taken. + +## Names + +The package, the binary and the systemd unit are called `dexd`: `dex` is already a Debian package name. A name like `dex-player` would need a second migration once ingest and playlists arrive. Paths keep the shorter name (`/opt/dex`). A future command to type should be `dexctl`, avoiding a collision at `/usr/bin/dex`. + +## Frame rate + +dexd targets 30 fps at 3840×2160. Higher rates are out of scope, not ruled out: on a Raspberry Pi 4 at 3840×2160, 40 fps decodes at 1.08× realtime and 60 fps at 0.753×, so the ceiling lies between them (measured; see [measurements.md](measurements.md)). The HDMI capture device the measurements run through offers no 4K mode above 30 Hz, so the question needs a display that accepts a higher rate. Fixing 30 fps now lets the ingest and playlist stages normalise to one rate. + +## Out of scope + +- Audio. dexd plays silent, and mpv is not silent-only, so audio stays possible — one reason hello_video could not be the long-term player. A projected drift of about 90 s per day between playback position and system uptime (assumed) is invisible in a silent loop but would be an audio-sync defect. +- Frame rates above 30 fps, as under [Frame rate](#frame-rate). +- Hardware other than the Raspberry Pi, such as a small x86 board or a commercial signage player: surviving a power cut is a property of the Pi's design and a firmware setting elsewhere. Revisit only if no Pi-based player works. +- A read-only root filesystem, closed rather than planned (decided). Installations have survived being switched off at the socket on the current arrangement. That evidence comes from dexOS cards, and the card that ships first is plain trixie with the package, so a card that comes back corrupt reopens the question. + +## Open questions + +- Whether `display_mode: auto` should refuse rather than warn when the connector cannot offer the resolution the sidecar states (see [sidecar.md](sidecar.md)), and whether the example in the refusal message should suggest `auto` at all, now that the package installs no config. +- A long-running test of the packaged build with real KMS output and the watchdog enabled: the run that showed the watchdog healthy used `vo=null`, so it rendered no picture; no long run covers both (see [measurements.md](measurements.md)). +- The path where a second in-place recovery starts before the first has finished is unexercised on a device: nothing recovered during the twenty-five-hour run (see [measurements.md](measurements.md)). +- Whether the requirement that the file starts with an IDR picture may be relaxed for a particular video; no criteria exist for that judgement. +- A Raspberry Pi 5 leg, budgeted as its own task. The project has no Pi 5 measurements, and whether Raspberry Pi's `+rpt2` ffmpeg build is needed there is unresolved: trixie ships `+rpt1`. +- Whether the trixie line ships one image that selects a display mode or separate 4K and HD images. +- Venting for the enclosure: a sealed passive case is viable but marginal, and a gallery warmer than the room of the sealed-enclosure test consumes the remaining thermal headroom (see [measurements.md](measurements.md)). +- Reading the asset through `mmap` after 1.0, in place of the single in-memory copy dexd makes at startup; the risk is a page fault inside the read callback (see [endless-stream.md](endless-stream.md)) blocking on SD-card input at a frame deadline. +- A development-versus-production toggle read from an input pin at startup, an idea and not built. Configuration needing no write to the filesystem survives a power cut and is visible without a keyboard. +- Rolling the REUSE licensing scheme out to the project's other repositories, and the remaining lint follow-ups: a `cargo clippy -- -D warnings` sweep and a Miri run over the library tests. + +## Alternatives + +| Option | Outcome | Why not | +|---|---|---| +| pivid | Not needed | mpv reached realtime 4K; pivid's 32-bit builds had failed | +| GStreamer with `kmssink` | Blocked upstream | `kmssink` cannot bind a SAND-tiled buffer, an upstream gap and not a misconfiguration (see [architecture.md](architecture.md)) | +| A custom player in Rust, roughly 2000 lines and 10–15 person-days | Held, unneeded | Last of the pre-committed fallback order — pivid, then GStreamer, then this; mpv reached realtime first | +| ffmpeg with `vout_drm` | Kept as a fallback | 1.92× realtime (measured; see [measurements.md](measurements.md)); used only if mpv ever falls short of realtime | +| VLC 4, and WPE WebKit through Cog | Untested since 2024 | Close then; retest before either is considered again | +| Rebasing dexOS onto trixie | Rejected | Would force working 1080p installations to migrate so new ones get 4K | diff --git a/docs/design/service-unit.md b/docs/design/service-unit.md new file mode 100644 index 0000000..3ef95a9 --- /dev/null +++ b/docs/design/service-unit.md @@ -0,0 +1,180 @@ +# The systemd unit + +A dex player runs unattended for weeks with no operator, and the only way to switch it off is to cut the mains. This page is for a developer editing `dexd.service`: what each setting does, the display wait it calls, the maintainer scripts around it, and what breaks when a setting changes. + +Terms are defined in [the glossary](../glossary.md); measured numbers and their conditions are in the [measurement record](measurements.md). + +## Ordering and display ownership + +The unit starts after `multi-user.target`, stops the console session on tty1 and waits for the assets directory `/opt/dex` to be mounted. + +``` +Conflicts=getty@tty1.service +After=multi-user.target +RequiresMountsFor=/opt/dex +``` + +A getty on tty1 holds DRM master, which would stop the player taking the display; `Conflicts=` stops the getty, so the player never fails with `device busy`. + +`/opt/dex` is the mount point of the dex card's data partition, where the video, its sidecar, and the [exhibit config](exhibit-config.md) live. Without `RequiresMountsFor=`, dexd starts before they are mounted and refuses. + +A fresh device needs `sudo systemctl set-default multi-user.target`, so no desktop session claims the display. + +## Restart policy + +The unit relaunches the player two seconds after any exit, with no limit on the number of restarts. + +``` +Restart=always +RestartSec=2 +StartLimitIntervalSec=0 # in [Unit] +``` + +Without `StartLimitIntervalSec=0`, systemd's default rate limit of 5 starts in 10 s puts the unit into a permanent `failed` state after a burst of crashes. + +The key belongs in `[Unit]`: systemd accepted it in `[Service]` before version 229 and moved it there in that release. systemd does not report a key in the wrong section at load time, so the unit starts with the default rate limit in force. `systemd-analyze verify` catches that mistake, which is why it runs in [CI](ci.md) on this unit. + +Do not use the restart loop to cover a missing dependency: add the `After=` or `RequiresMountsFor=` the player needs instead. + +Escalating to a reboot when restarts do not restore playback is planned; see the [roadmap](roadmap.md). + +## Service type + +The unit is `Type=simple` and grants the main process systemd's notify socket. + +``` +Type=simple +NotifyAccess=main +``` + +A `Type=notify` unit that never sends `READY=1` stays inactive, and the player has no ready moment before its endless stream begins. `NotifyAccess=main` makes `WatchdogSec=` work under `Type=simple`: it grants the socket without the `READY=1` handshake. + +## Start timeout + +The unit waits for a display before starting the player, with a start timeout longer than that wait. + +``` +TimeoutStartSec=150 +ExecStartPre=/usr/bin/dex-wait-hdmi +``` + +A projector can take 30 s to 90 s to present EDID while the Pi boots in about 15 s. `ExecStartPre` carries no `-` prefix, so a failed wait blocks the unit. + +`TimeoutStartSec` must exceed the script's own wait, `DEX_HDMI_TIMEOUT`, which defaults to 120 s; raise `TimeoutStartSec` whenever `DEX_HDMI_TIMEOUT` is raised. Without a `TimeoutStartSec=` line, Debian's `DefaultTimeoutStartSec` of 90 s applies to `ExecStartPre`: systemd kills the script at about 90 s with a generic `start-pre operation timed out` message, before the script prints its own diagnostic, so a projector needing 91 s to 120 s never gets its full wait. + +`ExecStartPre` runs before the main process is forked, and under `Type=simple` systemd starts counting `WatchdogSec=` at the exec of `ExecStart`. The display wait therefore consumes none of the 180 s watchdog budget (measured; see the [measurement record](measurements.md)). + +## Command line + +`ExecStart=/usr/bin/dexd` passes no arguments. What the player plays and how the display is driven come from `/opt/dex/exhibit.yaml` or `/opt/dex/exhibit.json`: `asset` names the video, `display_mode` and `kms_force` the display. The frame rate comes from the [sidecar](sidecar.md). + +A `--mode` value or a positional asset path here is a cross-check: dexd refuses to start on any disagreement with the config, naming both values. See [Startup checks](startup-checks.md). + +## Watchdog + +systemd kills a player that is alive but no longer progressing, and the restart policy starts it again; `WatchdogSec=180` is the window. + +dexd sends `WATCHDOG=1` once per health-check tick, about every 10 s, after that tick's recovery evaluation; the 600 s heartbeat never pings. The notify socket is non-blocking, so a full receiver queue is a dropped ping, not a retry. At 180 s, eighteen pings fit each window and about seventeen consecutive drops are needed before systemd kills the process (see the [measurement record](measurements.md)). + +180 s exceeds the worst in-place recovery episode (about 2 minutes, derived), so a watchdog kill cannot preempt a recovery that would have finished. + +A player that has stopped showing pictures gets no `time-pos` events, and two non-advancing health checks count as a stall. At most 3 in-place recoveries of about 20 s each follow, drawn from a budget that never refills; then dexd exits with code 1 (see [Failure handling](failure-handling.md)). + +One path can hang: writing to standard error while the system log is not accepting writes. On that path the process stops sending pings rather than the ping call blocking. + +Do not add a final ping to any exit path — the exit when the recovery budget is spent, an `END_FILE` event, and an mpv event-queue overflow. A ping sent just before that hang resets the `WatchdogSec=` countdown, and the hang goes undetected. + +## User and sandbox + +The player runs unprivileged: it reads `/opt/dex`, writes only its cache directory, and can open the display device. + +``` +User=dex +SupplementaryGroups=video render +ProtectSystem=strict +ProtectHome=yes +ReadOnlyPaths=/opt/dex +PrivateTmp=yes +NoNewPrivileges=yes +``` + +`dex` is a system user with no login, no password, and no home (`/nonexistent`), in the groups `video` and `render` only — what opening `/dev/dri` needs. + +Under `ProtectSystem=strict` the filesystem is read-only apart from `/var/cache/dexd`, so the player never writes boot configuration. `dex-exhibit-apply(1)` is the separate step an operator runs as root after editing the exhibit config; deploys in this project are manual throughout. + +## Cache directory + +mpv needs a writable cache directory, and systemd provides one outside the `dex` user's home. + +``` +CacheDirectory=dexd +Environment=XDG_CACHE_HOME=/var/cache/dexd +``` + +`CacheDirectory=` makes systemd create `/var/cache/dexd` owned by `User=`, keep it writable under `ProtectSystem=strict`, and remove it on purge. + +mpv resolves a shader-cache directory under `$XDG_CACHE_HOME`, falling back to `$HOME/.cache`, during video-output init and before it consults `--gpu-shader-cache`; setting that flag to `no` changes nothing on mpv 0.40. + +Without these two lines the service logs `Failed to create /nonexistent for shader cache` on every start. Keep both lines: the system log is the only diagnostic channel on a deployed player, and an error printed on every healthy start makes it useless. + +## Shutdown + +The unit stops the player with `SIGTERM` and waits five seconds. + +``` +KillSignal=SIGTERM +TimeoutStopSec=5 +``` + +There is no graceful shutdown; the player is built to survive the mains being cut. The kernel releases DRM master when the process exits, so the next start acquires it cleanly. + +## dex-wait-hdmi + +The script polls `/sys/class/drm/card*-HDMI-A-*/status` once per second until one connector reads `connected`, then exits 0; the wait lasts 120 s by default, and `DEX_HDMI_TIMEOUT` overrides it. The script globs the card number, because vc4 and v3d probe order makes `card0` and `card1` unstable across kernel versions. + +It installs to `/usr/bin`, like `dexd` and `dex-exhibit-apply`, because Debian policy reserves `/usr/local` for the local administrator. + +On timeout the script prints `dex-wait-hdmi: no connected HDMI connector after 120s` to standard error, naming the timeout in force, and exits 1. The unit then fails, and `Restart=always` retries the whole sequence instead of starting the player into a display that is not there. + +The wait is a fallback; the primary fix is at the KMS layer. A forced display mode whose `video=` token ends in `D` makes the connector read connected before a display is attached, so the Pi keeps the forced mode and the projector locks on when it warms up. `dex-exhibit-apply(1)` writes that token into `/boot/firmware/cmdline.txt` from `kms_force`. + +`dex-exhibit-apply(1)` writes the `video=` token only. An operator adds the `drm.edid_firmware=` part by hand on the same line, naming a saved copy of the display's EDID under `/lib/firmware`: + +``` +drm.edid_firmware=HDMI-A-1:edid/dex.bin video=HDMI-A-1:3840x2160@30D +``` + +Capturing the EDID file is an install-time step; see `dex-wait-hdmi(1)`. Even with a forced mode, the wait covers a swapped cable and a replaced projector whose saved EDID no longer fits. + +## Maintainer scripts + +`postinst` creates what the unit requires and cannot create itself, inside a `case "$1" in configure)` block so it runs only on the configure action: + +- the `dex` user, if `getent passwd dex` finds none; +- `/opt/dex`, root-owned and mode 0755, since the unit mounts it read-only and the player only reads. + +The group loop adds `dex` to `video` and `render`, skipping a group the system lacks. Adding a user to a group it is already in is a no-op, so re-running the script repairs dropped groups. + +The package installs no exhibit config and creates no `/etc/dex`. `postinst` creates no config; it reports three things without failing the install: + +- neither `/opt/dex/exhibit.yaml` nor `/opt/dex/exhibit.json` exists — the message names the three files a player needs (video, sidecar, exhibit config) and a minimal config; +- a config sits under `/etc/dex`, which dexd no longer reads; the package leaves it, since it must not delete a file an administrator created; +- a drop-in under `/etc/systemd/system/dexd.service.d` still passes `--mode`, left for the same reason. + +A disagreeing drop-in already makes dexd refuse, naming both values. The message covers one whose value agrees: the player starts, and where the display mode came from is no longer visible. + +`postrm` keeps the `dex` user and `/opt/dex`: the video is operator content the package never shipped, and the user may be named in something an operator wrote. Debian policy permits keeping system users. + +The package metadata sets `enable = true`, which hooks the unit onto `[Install] WantedBy=multi-user.target`, and sets `start = false`. A device that is only power-cycled comes back playing, and an operator picks the moment the unit takes DRM master. [Packaging](packaging.md) covers the rest. + +## Alternatives + +| Option | Outcome | Why not | +|---|---|---| +| `Type=notify` | The unit stays inactive | The player sends no `READY=1` | +| `StartLimitIntervalSec` in `[Service]` | systemd ignores the key | The default rate limit stays in force | +| Debian's default start timeout | systemd kills the wait at about 90 s | `dex-wait-hdmi`'s 120 s diagnostic never prints | +| A writable home for the shader cache | The player writes outside its cache directory | `CacheDirectory=` gives one systemd creates and removes on purge | +| Boot-config writes inside the player | The player runs as root | `dex-exhibit-apply(1)` does it as a separate step | +| Removing the `dex` user on purge | Frees a passwd entry | May break an operator's own unit or cron job | +| Starting the unit during install | Playback begins at once | The unit would take DRM master from the console session | diff --git a/docs/design/sidecar.md b/docs/design/sidecar.md new file mode 100644 index 0000000..6997b45 --- /dev/null +++ b/docs/design/sidecar.md @@ -0,0 +1,155 @@ +# Asset binding + +This page explains how dexd establishes that it is playing the intended bytes at the intended frame rate. It covers the sidecar file that carries both, the restricted JSON it is written in, the checksum check and the asset check that reads the leading NAL units at startup, and the `dex-sidecar` tool that produces sidecars. It is written for a developer reading the player's source. Where these checks sit among the others, and the exit code each refusal produces, are in [startup-checks.md](startup-checks.md). + +## Rate and identity + +A raw HEVC elementary stream carries no timestamps, so the file does not record its own frame rate. A player told 25 fps for a 30 fps video plays it a fifth slow for as long as the exhibition runs, with no error and every counter reading normal. The rate therefore travels beside the video, in a file dexd requires. + +The sidecar is `.json` next to the video: `artwork.265` gets `artwork.265.json`. Two keys are required. `fps` carries the rate as text, and `sha256` the checksum over the video's exact bytes. dexd reads the video into memory, parses the sidecar, takes the rate from it, and refuses to start when the checksum does not match. + +The sidecar records no display mode. The same 4K video plays scaled on a 1080p monitor, so the display mode is a property of the installation and lives in the exhibit config (decided) — see [exhibit-config.md](exhibit-config.md). + +The checksum and the asset check test different properties, and neither covers the other. The checksum proves the bytes are the ones that were prepared; the asset check proves those bytes have the shape a gapless loop needs. A truncated copy still carries intact leading NAL units, and an open-GOP video hashes correctly. + +## Sidecar grammar + +The sidecar is one flat JSON object whose values are strings or unsigned integers. Anything else is a parse error, and dexd refuses to start on a parse error: an unparseable sidecar and a missing one are the same operational fact (decided). + +| Key | Value | Role | +|---|---|---| +| `fps` | string | required; the frame rate | +| `sha256` | string | required; 64 hex digits | +| `width`, `height` | unsigned integer | optional; read only for the display-mode warning (see [startup-checks.md](startup-checks.md)) | +| `source`, `encoder_cmd` | string or unsigned integer | optional; free text, ignored | +| any other key | string or unsigned integer | ignored | + +Unknown keys are ignored, so a preparation tool can record more without breaking players already installed. The value restriction still applies to unknown keys. dexd refuses to start when any key's value is an array, a boolean, `null`, a float, a negative number or a nested object, and the message names the key, with the value or its type. + +`fps` is a string rather than a JSON number. `30000/1001` is not expressible as a JSON number at all, and 29.97, as a float, rounds. A 4K video decimated from 59.94 fps has a true rate of 30000/1001. After validation the string goes verbatim to mpv's `container-fps-override`; a rate written as a number is refused with a message giving the two spellings it should have used. + +A well-formed rate is a positive integer (`30`), a positive decimal (`29.97`) or a positive rational (`30000/1001`). Leading or trailing spaces, signs, exponent notation, a dangling `.` or `/`, and every spelling of zero are refused, a zero rate being a typo in every case. + +`sha256` must be 64 hex digits. Uppercase is accepted and stored lowercase, so a digest pasted from a tool that prints capitals still binds. `width` and `height` must be integers, and a missing required key is named in its refusal. + +### Duplicate keys + +`{"fps":"30","fps":"25"}` is an error; the document has to be unambiguous. Duplicate rejection is the only rule dexd implements itself: a JSON map keeps the last value with no error, so the sidecar deserialises through a hand-written map visitor over `serde_json` instead of a derived implementation. Lexing, escapes, surrogate pairs, trailing data and structural errors are `serde_json`'s. + +The rule covers a repeated key the player never interprets, because the rule is about the document, whatever a key means. A test names duplicate rejection on its own, so a refactor that swapped the visitor for a plain map would fail that test and no other. + +### Non-ASCII values + +Non-ASCII text is allowed in values, and string escapes include `\uXXXX` with UTF-16 surrogate pairs. Python's `json.dumps` turns every non-ASCII character into `\uXXXX` under its default settings, and Go's `encoding/json` does the same for `<`, `>` and `&`. A `source` filename such as `Zürich.mp4` therefore arrives in one of those forms, and refusing escapes would refuse a byte-perfect, correctly hashed video over an ingest tool's serialiser settings. + +Raw UTF-8 and the escaped spelling parse to the same value. A truncated escape, a non-hex digit, and a lone or mispaired surrogate are refused. + +## Frame-rate resolution + +The sidecar is the source of the rate. `--fps` on the command line is a cross-check. + +| Sidecar | `--fps` | Result | +|---|---|---| +| present | absent | the sidecar's rate | +| present | same string | the sidecar's rate | +| present | different string | refused, naming both values | +| absent | any | refused | + +Comparison is string equality, so `--fps 30` against a sidecar reading `30/1` is refused although the two name the same number. Dropping `--fps` clears the refusal, whichever value is wrong, since the sidecar decides regardless. Ingest tools and any script that starts the player should settle on one spelling of each rate. The systemd unit in the package passes no `--fps` — see [service-unit.md](service-unit.md). + +With no sidecar present, startup refuses. If `--fps` alone worked whenever the sidecar was absent, a deployed player could run unbound, so running without one takes two flags: `--test-rig-no-sidecar` together with `--fps`. Under that pair the sidecar is ignored even when the file exists, and an unusable `--fps` is refused — see [startup-checks.md](startup-checks.md). + +## Checksum verification + +Once the rate resolves, dexd hashes the bytes it read and compares the result with the sidecar's digest. On a mismatch dexd names both digests and exits 2. The message says that the video or the sidecar is stale, wrong or truncated, and that the video has to be prepared again. + +A test binds a sidecar to a full video, removes 20 bytes from the tail of the file on disk, and asserts exit 2 with `sha256` in the message. The leading NAL units stay intact, so the asset check passes the file. + +## The asset check + +The asset check, in `nal.rs`, passes when the VPS, SPS and PPS parameter sets have all appeared before the first slice NAL unit and that slice is an IDR. An IDR is NAL type 19 or 20 in H.265 Table 7-1 (documented). Everything after the first slice is out of scope; the checksum covers the rest of the file. + +Byte 0 has to begin a closed GOP for the loop to be gapless: returning there mid-stream is then an ordinary keyframe rather than a seek. A video of any other shape plays, and then breaks at every loop point with no error — see [endless-stream.md](endless-stream.md). + +The check requires an IDR rather than any keyframe of the wider IRAP family. CRA (type 21) starts an open GOP whose leading pictures, RASL, may reference pictures before it, so whether such a video loops cleanly depends on its content; BLA (broken-link access, 16–18) does not come out of a working ingest, and 22 and 23 are reserved (decided). + +dexd finds NAL boundaries with a plain search for `00 00 01`. Both the three- and four-byte start-code forms contain that pattern, so one scan handles both. Encoders insert emulation-prevention bytes, padding that keeps `00 00 01` from occurring inside a payload, so on a well-formed stream the scan cannot match anything else. + +The scan passes over non-slice units before or among the parameter sets: an access unit delimiter, which marks a picture boundary, or a supplemental-enhancement-information unit, which carries metadata beside the pictures. A start code with fewer than two bytes after it ends the scan like any other end of data. + +| Condition | The refusal names | +|---|---| +| no start code anywhere | that this is not a raw HEVC stream, with the ffmpeg command that repackages an MP4 into one without re-encoding | +| `forbidden_zero_bit`, the first bit of the NAL header, is set | a corrupt NAL header | +| first slice before a parameter set | the missing sets, by name | +| first slice is a CRA | CRA, and a closed-GOP re-encode | +| first slice is another type | its type number | +| parameter sets but no slice | that no slice was found | + +A parameter set appearing after the first slice does not satisfy the requirement retroactively; the refusal still names it missing. + +Two end-to-end tests give the sidecar a matching digest so that only the asset check can refuse: on 64 KiB containing no start-code byte dexd exits 2 with `start code` in the message, and on a CRA-led stream with `CRA`. + +## Checksum implementation + +The checksum module in `sha256.rs` returns 64 lowercase hex characters and computes them with `sha2`, the SHA-256 implementation from the RustCrypto crates. The module keeps its own API, so its test vectors check the digest the module returns, independent of the crate behind it. The vectors are the NIST ones from FIPS 180-4 (documented) — the empty input, `abc`, the 56-byte message and one million `a` bytes — plus six input lengths chosen around SHA-256's 64-byte padding boundary. + +The digest the module returns is part of the sidecar's data format. A change producing different bytes would invalidate every sidecar already written and turn the checksum check into a refusal on every deployed player. + +## dex-sidecar + +`dex-sidecar write` produces a sidecar and `dex-sidecar check` verifies an existing pair. Both parse the sidecar and verify the digest through the library the player links, and both read the video with the same call the player makes. A sidecar this tool accepts is therefore one dexd accepts, and the format has no second implementation to drift from the first. The command lines and options are in [../guides/reference.md](../guides/reference.md), and the workstation procedure in [../guides/prepare-video.md](../guides/prepare-video.md). + +The package does not install the tool: a video is prepared on a workstation, never on the player (decided). `dex-sidecar` links only the parser and the hash, not libmpv and not a DRM device, so it builds and runs on a workstation as it does on the player. + +`check` prints `OK fps=… sha256=… width=… height=…` when the sidecar parses and its digest matches the file. Both subcommands share one exit-code contract: 0 on success; 1 when a file cannot be read or a verification fails; 2 on a refusal or a malformed command line. + +Beside each encoded video, example-content also ships companion files for other players — a JSON file for pivid, a web page for a browser-based player. Those files are unrelated to this format. + +### Frame rate at ingest + +Without `--fps`, the rate comes from ffprobe's `r_frame_rate`, which for a raw stream is only as good as the timing the encoder wrote into the SPS. Where there is none, ffprobe answers with its internal timebase, 1200000/1, which is not a frame rate. `dex-sidecar` therefore accepts a reported rate only between 1 and 1000 fps — generous enough for any real camera or encoder, and three orders of magnitude below that timebase. + +Outside the bound, or with ffprobe missing or failing, `write` refuses and asks for `--fps`. A rate in range is reduced to lowest terms and printed in a note that says where it came from. + +With `--fps` and a rate in range both present, `write` compares the two as decimals rounded to six places and refuses when they differ by more than 0.02 fps. Two spellings of one rate do not disagree; `3` against `30` does, and a difference that size reads as an ingest typo. `--force` proceeds with a warning, and also permits replacing a sidecar that already exists. + +`write` validates an explicit `--fps` by building a sidecar around the value and asking dexd's parser whether it reads back unchanged. That also keeps a value carrying quotes or backslashes from reshaping the JSON around it. + +### Writing + +`write` writes `.json` unless `--out` names another path; that default is the only name dexd looks for. The written file is one line: `fps` and `sha256`, then `width` and `height` when ffprobe reported both. Where ffprobe reported only one of them or neither, `write` leaves both out and prints a note saying so. dexd reads `width` and `height` only for the display-mode warning — see [startup-checks.md](startup-checks.md). + +Before the file reaches its name, `write` parses its own output with dexd's parser, compares the values that come back with the values that went in, and re-verifies the digest against the bytes. On a failure `write` reports a defect in itself and writes nothing. The accepted text then goes to a temporary name in the same directory and is renamed over the target, so an interrupted run cannot leave half a sidecar where dexd will look for a whole one. + +## Test scope + +The grammar, the frame-rate resolution and the NAL rules are pure functions with unit tests that run on a workstation. The tests that spawn dexd enforce the refusals and their exit codes against real files. + +The `write` tests build HEVC streams with ffmpeg in two shapes — one whose encoder wrote frame timing into the stream and one whose encoder did not. They cover: + +- a fresh write; +- the refusal to overwrite, and `--force` overwriting; +- an `--fps` that contradicts the stream; +- a rate that has to be supplied because none can be read; +- `check` failing after one byte in the middle of the stream is flipped. + +See [development.md](development.md). + +## Alternatives + +| Option | Outcome | Why not | +|---|---|---| +| Take the frame rate from the command line | A deployed player runs at a rate nobody bound to the file | The sidecar binds the rate to the bytes | +| Record the display mode in the sidecar | A forced mode contradicts the connected display | The mode belongs to the installation, not the file | +| Accept any JSON value under ignored keys | A sidecar with a nested object or an array parses | Skipping arbitrary values needs recursion in a parser whose only job is refusing predictably, and no tool emits them | +| Accept any IRAP keyframe (16–23) | A CRA-led video passes and breaks at every loop point | Whether it loops cleanly depends on the content | +| Refuse non-ASCII bytes in sidecar values | A correctly hashed video is refused over its source filename | Filenames are routinely non-ASCII | +| Reimplement the grammar in the ingest tool | Two grammars drift, and a sidecar passes one and fails the other | The tool links the player's parser | +| Write the sidecar with a shell `printf` | A hand-edited file is first read by the player, at the venue | `dex-sidecar write` reads its own output back | + +## Open questions + +Whether the IDR-only requirement should ever be relaxed for a particular video is undecided. The check defines no criteria for such a relaxation; relaxing the requirement needs a criterion written down first. + +A video corrupted before it was prepared hashes correctly, and the sidecar then records that corruption as the intended bytes. Ending preparation with a test play on real hardware is decided and not built — see [roadmap.md](roadmap.md). diff --git a/docs/design/startup-checks.md b/docs/design/startup-checks.md new file mode 100644 index 0000000..4b1a6f4 --- /dev/null +++ b/docs/design/startup-checks.md @@ -0,0 +1,127 @@ +# Startup checks + +This page lists everything dexd verifies before it hands the video to mpv, in the order it runs them, with what makes each one refuse. It is for a developer reading the source or diagnosing a player that will not start. The same messages with their fixes, for a technician, are in [../guides/reference.md](../guides/reference.md). + +## Fail-closed startup + +dexd refuses to start when an input is missing or ambiguous, rather than substituting a default. If dexd substituted a default, the player would run with the wrong picture and every metric normal: a guessed frame rate, display mode, or asset plays that way for the length of the exhibition. + +No operator is present at an unattended installation to notice a wrong input later, so dexd refuses it at startup (decided). + +Any path that degrades without reporting it counts as a bug even when playback works: + +- an idle hang +- a fallback to software decoding +- a discarded log +- a wrong `--fps` + +Every startup check that refuses exits 2 with a message naming the repair. + +## Order of checks + +| Check | Refuses when | Skipped by `--test-rig-no-sidecar` | +|---|---|---| +| Argument shape | a flag's value is missing or does not parse; a token is unrecognised | no | +| Test-rig flag pairing | a test-rig timer flag is given without `--test-rig-no-sidecar` | — | +| Exhibit config | none exists; two exist at once; a path given with `--exhibit-config` is absent; the file cannot be read or parsed; a key or value type is outside the schema | yes | +| Display mode | `--mode` contradicts the config's `display_mode` | yes | +| Cmdline check | the config's `kms_force` disagrees with the kernel's `video=` entry for the connector, or that entry names no connector | yes | +| Mode pre-flight | the resolution is not among the modes the connector lists | no | +| Asset resolution | no asset is named anywhere; a command-line path contradicts the config | the path must come from the command line | +| Asset read | the file is unreadable or empty | no | +| Sidecar | it is absent, or outside the strict JSON subset | yes | +| Frame rate | `--fps` contradicts the sidecar | `--fps` becomes required | +| Checksum | the asset's SHA-256 does not match the sidecar's | yes | +| Leading NAL units | the stream does not begin with VPS, SPS, and PPS followed by an IDR | no | +| mpv options | libmpv rejects an option name or value | no | + +The display checks run before the asset is read; they are the cheapest in the program. dexd therefore reports a resolution the connector does not list even when the asset path is also wrong. + +`--test-rig-no-sidecar` skips everything that reads deployment state: the exhibit config and the `--mode` cross-check against it, the kernel command line, and the sidecar with the frame-rate and checksum checks that depend on it. The mode pre-flight reads the connected hardware, so it runs either way. + +## Argument shape + +`ExecStart=/usr/bin/dexd` passes no arguments, so dexd has no argument-shape guard for an empty command line: a bare `dexd` proceeds to the exhibit config, which names the asset, and refuses there if the config does not name one. + +dexd takes the first token that does not start with `-` as an asset path, which must then agree with the config's resolved path (see [exhibit-config.md](exhibit-config.md#asset-resolution)). Any other unrecognised token prints usage and exits 2. + +A flag whose value is missing or malformed also prints usage. Read as absent instead, a dropped `--fps` would skip the sidecar cross-check and a dropped `--mode` would fall back to the mode the connector prefers, both without an error. + +## Resolution and refresh + +The mode pre-flight validates the resolution half of `display_mode`. The kernel lists one `WxH` per line in `/sys/class/drm/card*-/modes` and gives no refresh column, so dexd checks the `@R` half against the grammar alone. mpv settles the refresh at video-output init and reports `Could not find mode matching 3840x2160@60` for one the connector does not offer; the symptom and its fix are in [../guides/run-check-troubleshoot.md](../guides/run-check-troubleshoot.md). dexd skips the pre-flight for `display_mode: auto`, which names no resolution. + +Reading the modes file needs no privilege and no libmpv, so the check can run this early. The grammar and the reconciliation of the forced display mode with the kernel `video=` token are in [exhibit-config.md](exhibit-config.md). + +## Exit codes + +| Code | Meaning | +|---|---| +| 2 | Refused before playback. Fix the invocation, config, asset, or sidecar and redeploy; a restart cannot help. | +| 1 | Playback or runtime failure. The service manager restarts the process. | +| 0 | Never, in normal operation: the player is designed not to end. | + +dexd exits 2 when libmpv rejects an option: the same asset and flags fail identically on every restart. + +More than one site returns exit 1. The stderr line names which failure fired, and dexd prints `playback ended` when the stream ends. Runtime behaviour is in [failure-handling.md](failure-handling.md). + +## Startup lines + +dexd prints its version and commit to stderr before it parses any argument, so a refused start still names the build that refused. + +dexd prints four lines before it hands the video to mpv, shown here for the 3 s 4K test video (14.8 MB, see [measurements.md](measurements.md)): + +``` +dexd 0.1.0 () +dexd: display auto (exhibit config /opt/dex/exhibit.yaml), connector HDMI-A-1, kms-force none +dexd: asset /opt/dex/artwork.265 (exhibit config /opt/dex/exhibit.yaml) +dexd: 14800000 bytes, fps 30 (sidecar), looping endlessly +``` + +Each line names where its value came from: `exhibit config `, `sidecar`, `command line, not the exhibit config` for an asset given on the command line, or `test rig override, unbound` under `--test-rig-no-sidecar`, where the kms-force field also reads `not checked (test rig)`. With no config found, the display line names the path dexd's refusal asks the operator to create. + +## Message rules + +### Both repairs + +The cmdline check compares the exhibit config with the running kernel's command line, and either can be the outdated one. On a device whose command line carries a force the venue needs, a message prescribing `dex-exhibit-apply` alone would tell the operator to delete that force, after which `display_mode: auto` plays at whatever the connector negotiates. dexd states both values and both repairs: update the config, or run `dex-exhibit-apply` and reboot. + +### Distinct messages + +A config that is absent, one that is present but unreadable, and one whose path cannot be read at all (`cannot stat`) have three different repairs, so dexd reports them separately. A named `--exhibit-config` file that does not exist gets its own message, pointing back at the assets directory `/opt/dex`: the advice to create `/opt/dex/exhibit.yaml` would be wrong for someone who has just named another path. + +### The line to add + +When no config names an asset, dexd prints the key in both formats, `asset: loop.265` for YAML and `"asset": "loop.265"` for JSON. The package installs no config, and its postinst prints the minimal file to create, so the operator sees the missing config during the install rather than at the next power cycle. + +## Test-rig-only override + +`--test-rig-no-sidecar` runs dexd on a test rig with no deployment state: it consults no exhibit config and no sidecar, and takes `--fps` and `--mode` as given. + +- It requires `--fps`, and exits 2 without one: a deployed player would otherwise take its frame rate from the command line whenever the sidecar was absent, with nothing tying that rate to the video. +- `--mode` is optional and defaults to `auto`. +- The asset must come from the command line, and a sidecar that happens to be present is ignored. + +Two flags force a failure on purpose: `--test-rig-force-recovery-after-secs` triggers an in-place recovery on a timer, and `--test-rig-hang-after-secs` hangs the supervisor thread. dexd refuses both with exit 2 unless `--test-rig-no-sidecar` is present, deciding on the command line's shape before reading the asset. Each prints a warning naming itself when armed. The packaged unit passes neither, so neither can be enabled against a deployed asset. + +## Refusal output + +Every refusal reaches the system log and nothing else: the unit conflicts the console getty away so dexd can take DRM master, and systemd routes stderr there. At the venue every failure therefore shows as a dark screen with no message. The triage command is `journalctl -u dexd -n 20`. + +dexd does not write the last refusal to tty1. The unit's stop handler could; that is decided and not built, pending a check of which process owns the text console on the target hardware. + +## Test scope + +The decision tables behind these checks — the display-mode grammar, display, asset, and frame-rate resolution, the cmdline comparator and the pre-flight parser — are pure functions, exhaustively unit-tested on a workstation. The tests that spawn the binary enforce what those cannot: check order, flag parsing, and the refusal messages. One case stays in the unit tests, a host with no config anywhere: at the binary level its outcome depends on the assets directory of whichever machine runs the suite. See [development.md](development.md). + +## Alternatives + +| Option | Outcome | Why not | +|---|---|---| +| Guess the frame rate when the sidecar is absent | Plays at the wrong speed indefinitely, with no error | The failure is undetectable from inside the running system | +| Default the asset to a fixed path | Plays the previous installation's video after a mistyped config key | Widest consequence of any guess here | +| Resolve two exhibit configs by precedence | Runs yesterday's display mode without saying so | Only the operator knows which file is meant; deleting one is one command | + +## Open questions + +dexd skips the mode pre-flight for `display_mode: auto`, so a display that, unforced, cannot offer the asset's resolution plays at whatever it negotiates. In that case dexd compares the sidecar's stored width and height against the connector's mode list and warns without refusing. Whether it should refuse, and whether `auto` should remain the default at all, are undecided. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..9411100 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,1181 @@ +# Glossary + +Terms used in the dex documentation. Every entry was reviewed and approved by the project owner; changes go through a pull request that CODEOWNERS routes to him. Entries marked **user** are the only technical terms the user guides use without explanation; **developer** entries may appear in the design documents. + +Writers: a term that is not in this list is either plain English or must be explained in the sentence that uses it. See `AGENTS.md` for the writing rules and the list of retired words. + +## User-tier terms + +### .265 file + +A video file that holds only the compressed HEVC pictures, with no wrapper such as MP4 around them. dexd plays this format and nothing else, so every video is converted to a .265 file before it goes on the player. + +Also written: raw HEVC stream · raw Annex-B file · .h265 · elementary-stream file + +Tier: user + +### .deb package + +The installable package file for Debian-based systems such as Raspberry Pi OS. dexd is delivered as one .deb, installed with apt, which also pulls in the mpv library it needs and sets up the service that starts at boot. + +Also written: Debian package · the .deb · dexd__arm64.deb + +Tier: user + +### asset + +The one video file the player loops — the video asset — named by the `asset` line of the exhibit config, as a file name next to the config (for example `artwork.265`) or an absolute path. If no asset is named, dexd refuses to start rather than guess. The artwork is the whole installation the asset plays in. + +Also written: video asset · the video · `asset` (config key) + +Tier: user + +### checksum + +A short code computed from every byte of a file; change one byte and the code changes. The sidecar stores the video's checksum, so dexd can tell a stale, wrong or half-copied file from the prepared one, and refuses it. + +Also written: SHA-256 · sha256 · hash · fingerprint + +Tier: user + +### cmdline.txt + +The one-line file on the Raspberry Pi's boot partition that holds the start-up options for the operating system, including a forced display mode. dex-exhibit-apply edits it from the exhibit config; do not hand-edit it, and reboot after it changes. + +Also written: /boot/firmware/cmdline.txt · kernel command line · boot options file + +Tier: user + +### connector + +The name the operating system gives each physical video output; on a Raspberry Pi 4 the two HDMI ports are HDMI-A-1 and HDMI-A-2. The exhibit config names the connector the display is plugged into (default HDMI-A-1), and every display check uses it. + +Also written: `connector` (config key) · HDMI-A-1 · HDMI-A-2 · HDMI port + +Tier: user + +### dex card + +An SD card holding Raspberry Pi OS, dexd, the exhibit config and the video, which turns a Raspberry Pi into a player the moment it boots. Building one is the setup task; a spare card is the fastest repair at a venue. + +Also written: player card · card · SD card · exhibition card + +Tier: user + +### dex-exhibit-apply + +A helper command installed with dexd, run with sudo after editing the exhibit config. It writes the forced display mode from the config into cmdline.txt, changing nothing else, and prints REBOOT REQUIRED only when the file actually changed. + +Also written: exhibit-apply · `sudo dex-exhibit-apply` + +Tier: user + +### dex-sidecar + +The command that writes a video's sidecar (`dex-sidecar write`) and checks an existing one against its video (`dex-sidecar check`), run on a workstation before copying to the player. It uses dexd's own reader, so what passes here plays there. + +Also written: dex-sidecar write · dex-sidecar check · sidecar-check (old name) · make-sidecar.sh (old script) + +Tier: user + +### dex-wait-hdmi + +A helper that runs before dexd and waits, up to two minutes, for a display to report it is connected. Projectors can wake slower than the Raspberry Pi boots; without the wait the system picks a fallback resolution and never corrects it. + +Also written: DEX_HDMI_TIMEOUT (its timeout setting) + +Tier: user + +### dexd + +The player program: it plays one .265 video on a Raspberry Pi in an endless gapless loop, starts at boot as a system service, checks its own health and restarts itself. Package, command and service share the name; helpers keep the dex- prefix. + +Also written: dex-loop (name during development) · dexd.service · the player + +Tier: user + +### display mode + +The picture size and refresh rate the player asks the display for, written as WIDTHxHEIGHT@RATE (for example 3840x2160@30) or `auto`. Set it in the exhibit config to match the display; a mode the display cannot show makes dexd refuse to start. + +Also written: `display_mode` (config key) · WxH@R · resolution and refresh · auto + +Tier: user + +### EDID + +The information a display sends over the HDMI cable describing itself and the modes it can show. dexd and the Raspberry Pi rely on it to choose a mode; some displays send it late or wrongly, which is why kms_force and dex-wait-hdmi exist. + +Also written: display identification · the display's self-description · edid-decode (tool that prints it) + +Tier: user + +### exhibit config + +The one file on each player that says which video plays (`asset`), which display mode to use and which connector. It lives next to the video, as `/opt/dex/exhibit.yaml` or `exhibit.json`; the package installs none. dexd reads it at every start; the command line may cross-check it but never override it. + +Also written: /opt/dex/exhibit.yaml · /opt/dex/exhibit.json · exhibit.yaml · exhibit.json · exhibit file · exhibit · the installation · venue setup · `venue` / `display` / `note` (informational config keys) + +Tier: user + +### exit codes + +The number dexd returns when it stops. 2 means it refused to start because something in the setup is wrong (fix the config or files; a restart will not help); 1 means playback failed while running, and the service manager restarts it automatically. + +Also written: exit-code contract · exit 1 · exit 2 + +Tier: user + +### ffmpeg + +A command-line tool that converts video between formats. The prepare-video guide uses it to encode HEVC with the settings dexd needs and to extract the .265 file; ffprobe, from the same toolkit, reports a file's size, frame rate and codec. + +Also written: ffprobe (its inspection tool) · libx265 / x265 (its HEVC encoder) · hevc_mp4toannexb (its MP4-to-.265 filter) + +Tier: user + +### forced display mode + +An exhibit-config setting (`kms_force`) that makes the Raspberry Pi output a fixed mode from boot (for example 3840x2160@30) instead of trusting what the display announces, or `none`. Needed for displays that announce 4K but never get it unforced; dex-exhibit-apply writes it into cmdline.txt. + +Also written: `kms_force` (config key) · WxH@R / WxH@RD + +Tier: user + +### frame rate + +How many pictures per second a video shows, for example 30 or 29.97 (written 30000/1001). A .265 file does not record it, so dexd takes it from the sidecar and refuses to start without one rather than play at the wrong speed. + +Also written: fps · frames per second · `fps` (sidecar field) · `--fps` + +Tier: user + +### gapless + +Playback that repeats with no visible break: no black frame, no held frame, no stutter between the last picture and the first. It is the property dexd exists to deliver, and what every measurement in the design record checks. + +Also written: seamless · seamless loop · perfect loop · loops seamlessly + +Tier: user + +### hardware decoding + +Turning compressed video back into pictures using a dedicated block in the chip instead of the main processor. A Raspberry Pi 4 can play 4K HEVC smoothly only this way, so dexd is built around it; software decoding is the slow fallback. + +Also written: hardware decode · HW decode · hardware-accelerated video · hwdec (mpv's option for it) + +Tier: user + +### heartbeat + +A status line dexd writes to the system log at start and every ten minutes: loop count (`loops=`), uptime, chip temperature, dropped and late frames, playback-position age, watchdog state. While it keeps coming the player is alive. It reports; the health check repairs; the watchdog restarts. + +Also written: heartbeat line · `loops=` (was `wraps=`) · `temp=` · `frame-drops=` · `vo-delayed=` · `pos=` · `pos-age=` · `watchdog=` + +Tier: user + +### HEVC + +The video compression format dexd plays, also called H.265. The Raspberry Pi 4 has a hardware decoder for it and for nothing newer, so 4K playback depends on the video being HEVC; other formats must be re-encoded first. + +Also written: H.265 · High Efficiency Video Coding + +Tier: user + +### keyframe + +A picture in a compressed video that is complete on its own, not described as changes from earlier pictures. A video for dexd must begin with one and must not let later pictures refer back across the start, or the loop cannot restart cleanly. + +Also written: intra frame · I-frame · IDR (the exact HEVC term, developer glossary) + +Tier: user + +### loop point + +The moment playback returns from the video's last picture to its first. Everything about a gapless loop is decided here: a pause, a held frame or a flash at the loop point is the defect dexd is designed to avoid and its measurements look for. + +Also written: restart of the loop · wrap point (retired wording) · the wrap (retired wording) · seam (retired wording) + +Tier: user + +### mpv + +The open-source media player whose engine dexd uses to decode and show video. dexd does not run the mpv program; it embeds mpv's library and feeds it the video, which is why installing dexd also installs the mpv library package (libmpv2). + +Also written: mpv 0.40 (the verified version) + +Tier: user + +### one-file rule + +Only one exhibit config may exist on a player: exhibit.json or exhibit.yaml, never both. If both are present dexd refuses to start and names both, so a venue never runs yesterday's settings from the file nobody edited. + +Also written: exactly one exhibit config · the extension decides the parser + +Tier: user + +### Raspberry Pi Imager + +The official program that writes Raspberry Pi OS onto an SD card and lets you set the hostname, user and SSH key before first boot. The player-card guide starts with it and sets the SSH key here. + +Also written: Imager + +Tier: user + +### sidecar + +A small text file next to the video, named like the video plus `.json`, holding its frame rate and checksum. dexd refuses to start unless it is present and matches, so a wrong frame rate or a half-copied video is caught before anything shows. + +Also written: