diff --git a/.github/workflows/drm-capture.yml b/.github/workflows/drm-capture.yml new file mode 100644 index 000000000..2efc6eabd --- /dev/null +++ b/.github/workflows/drm-capture.yml @@ -0,0 +1,449 @@ +name: DRM capture (opt-in drm feature) + +# Least-privilege GITHUB_TOKEN. Every job here only checks out, builds and tests; the artifact +# up/download used by the deb job authenticates with the runtime token, not this one. Declared at +# the workflow level so the reusable bridge workflow called below inherits the same bound. +permissions: + contents: read + +# Supersede a stale run when a PR is pushed again; never cancel a master run, whose whole job is to +# record that a given commit on master was verified. +concurrency: + group: drm-capture-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +# Everything CI-side about the opt-in `drm` backend lives here, so the stock CI and release workflows +# stay byte-identical to a build with the feature off. Nothing in this file runs unless a drm-related +# path changes (or someone dispatches it by hand), so a PR that does not touch the backend pays nothing. +# +# The stock `CI` workflow deliberately does NOT compile with `--features drm`: the shipped default is +# the drm-off configuration and that stays the primary verified one. + +on: + workflow_dispatch: + pull_request: + paths: + - "libs/scrap/src/common/drm_reader.rs" + - "libs/scrap/src/common/drm_render.rs" + - "libs/scrap/src/common/drmtap_dl.rs" + - "libs/scrap/src/common/mod.rs" + - "libs/scrap/Cargo.toml" + # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes + # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: + # measured over the last 100 commits, it alone would have fired this workflow 13 times and + # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release + # build, almost always for a dependency the drm path never touches. A lockfile bump that + # does affect it arrives with a manifest or source change, which is triggered above. + - "Cargo.toml" + - "src/ipc.rs" + - "src/ipc/**" + - "src/server/drm_capturer.rs" + - "src/server/wayland.rs" + - "src/server/display_service.rs" + # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the + # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the + # whole drm verification. + - "src/server.rs" + - "src/server/input_service.rs" + - "src/platform/linux.rs" + - "build.py" + - ".github/workflows/drm-capture.yml" + push: + branches: + - master + # Deliberately the SAME list as the pull_request trigger above: a shorter one here means a push + # that touches only the missing paths (a squash merge, a direct push) skips re-verification. + paths: + - "libs/scrap/src/common/drm_reader.rs" + - "libs/scrap/src/common/drm_render.rs" + - "libs/scrap/src/common/drmtap_dl.rs" + - "libs/scrap/src/common/mod.rs" + - "libs/scrap/Cargo.toml" + # The ROOT manifest is where the top-level `drm` feature is DEFINED, so a PR that changes + # what `drm` pulls in must not skip this workflow. `Cargo.lock` is deliberately NOT here: + # measured over the last 100 commits, it alone would have fired this workflow 13 times and + # the pair 24 times, each run costing about two job-hours for a full vcpkg + flutter release + # build, almost always for a dependency the drm path never touches. A lockfile bump that + # does affect it arrives with a manifest or source change, which is triggered above. + - "Cargo.toml" + - "src/ipc.rs" + - "src/ipc/**" + - "src/server/drm_capturer.rs" + - "src/server/wayland.rs" + - "src/server/display_service.rs" + # These three carry DRM wiring too (warm_availability, the cursor path in run_cursor, and the + # producer start + get_cursor/get_cursor_data), so a PR touching only them must not skip the + # whole drm verification. + - "src/server.rs" + - "src/server/input_service.rs" + - "src/platform/linux.rs" + - "build.py" + - ".github/workflows/drm-capture.yml" + +env: + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" + FLUTTER_VERSION: "3.24.5" + +jobs: + drm-tests: + name: drm unit tests (linux) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + persist-credentials: false + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get -y update + sudo apt-get install -y \ + clang cmake curl gcc git g++ \ + libpam0g-dev libasound2-dev libunwind-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libxfixes-dev nasm wget + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + + - name: Install vcpkg dependencies + shell: bash + run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # The whole rustdesk-crate test set with the feature ON, not just the `_drm` ones by name: a name + # filter would skip the sibling asserts that also matter in this configuration, notably the one + # bounding `size_of::()`, which the new DmabufDesc variant grows. + # The two skips are the same ones the stock CI applies: both need a real display server and fail + # on a headless runner regardless of this feature. + - name: Run rustdesk crate tests with the drm feature + shell: bash + run: | + cargo test --locked --target x86_64-unknown-linux-gnu -p rustdesk --features drm \ + --no-fail-fast -- --skip test_get_cursor_pos --skip test_get_key_state + + # The capture backend itself lives in the scrap crate, so its unit tests are a separate + # package. `--lib` keeps this to unit tests; none of them touch a device or a display server. + - name: Run scrap crate tests with the drm feature + shell: bash + run: | + cargo test --locked --target x86_64-unknown-linux-gnu -p scrap --features drm --lib + + libdrmtap: + name: libdrmtap pin, build and .so contract + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install libdrmtap build deps + shell: bash + run: | + sudo apt-get -y update + sudo apt-get install -y meson ninja-build pkg-config libdrm-dev \ + libegl1-mesa-dev libgles2-mesa-dev + + # Exercises the real fetch-and-build path in build.py, which pins the commit by sha, so a bad or + # moved pin fails here rather than in a release job. + - name: Fetch the pinned libdrmtap and build the .so + shell: bash + run: | + python3 - <<'PY' + import importlib.util, sys + spec = importlib.util.spec_from_file_location("b", "build.py") + b = importlib.util.module_from_spec(spec) + sys.argv = ["build.py"] + spec.loader.exec_module(b) + so = b.build_libdrmtap_so() + print(f"::notice::built {so}") + open("so_path", "w").write(so) + PY + + # The shipped hot path is the EGL detile. libdrmtap degrades to a CPU-only stub when the egl or + # glesv2 pkg-config files are missing on the build host, and nothing else in the pipeline notices, + # so assert here that the object we would ship really carries EGL and really exports every symbol + # the runtime loader resolves. + - name: Assert the .so contract (EGL enabled, loader symbols present) + shell: bash + run: | + # Strict mode is load-bearing here: without it the trailing ::notice echo would return 0 + # and mask the `test "$missing" -eq 0` assertion, so the step would pass with a missing + # loader symbol or a CPU-only stub. (pipefail also keeps the grep -c pipelines honest.) + set -euo pipefail + SO="$(cat so_path)" + echo "checking $SO" + missing=0 + # Every symbol drmtap_dl.rs resolves, derived from the loader itself so the two cannot + # drift. The character class allows digits (a drmtap_grab_desc2 would otherwise be + # silently dropped from the loop), and the count is asserted below so a refactor of the + # loader away from b"..." literals cannot quietly turn this whole check into a no-op that + # iterates zero times and passes. + # `|| true` on the extraction pipelines: under set -e/pipefail a zero-match grep would + # abort the script before the explicit ::error guard below can say WHY it failed; the + # guard on nsyms is the intended reporter for that case. + syms=$(grep -oE 'b"drmtap_[a-z0-9_]+"' libs/scrap/src/common/drmtap_dl.rs \ + | sed 's/^b"//; s/"$//' | sort -u || true) + nsyms=$(echo "$syms" | grep -c . || true) + if [ "$nsyms" -lt 13 ]; then + echo "::error::extracted only $nsyms loader symbols from drmtap_dl.rs (expected >= 13); the extraction pattern no longer matches the loader" + missing=1 + fi + # Inspect the object ONCE into a variable, then match with bash's own pattern operator -- + # NO PIPE ANYWHERE IN THESE CHECKS. `anything | grep -q` under `set -o pipefail` reports a + # FALSE FAILURE as soon as the producer outruns the 64 KB pipe buffer: grep -q exits at the + # first match, the producer dies on SIGPIPE (141), and pipefail makes that the pipeline's + # status, so a library that HAS the symbol is reported as missing it. Measured on a real + # EGL-enabled .so (101 KB of `strings`, both markers present): the piped form reported both + # missing and failed the step. Note the obvious repair does NOT work -- materializing the + # output and then doing `printf '%s\n' "$var" | grep -q` keeps the pipe and just swaps the + # producer, and it fails identically (measured). Today's release-sized .so happens to fit in + # the buffer, which is the only reason this has not fired yet. + exported="$(nm -D --defined-only "$SO")" + strs="$(strings "$SO")" + for sym in $syms; do + # Line-anchored: wrap in newlines so the pattern can require a whole line, the same + # thing `grep " T $sym$"` was expressing. + if [[ $'\n'"$exported"$'\n' != *$'\n'*" T $sym"$'\n'* ]]; then + echo "::error::libdrmtap does not export $sym, which the runtime loader resolves" + missing=1 + fi + done + # EGL is reached by lazy dlopen, on purpose, so that the privileged process never links the + # vendor GL stack. That means there is NO DT_NEEDED entry and no undefined egl* symbol to look + # for: the naive ELF check reports "no EGL" on a perfectly good library. What a CPU-only stub + # build really lacks is the dlopen target name and the import call itself. + for s in "libEGL.so.1" "eglCreateImageKHR"; do + if [[ "$strs" != *"$s"* ]]; then + echo "::error::libdrmtap looks like a CPU-only stub (no $s): the EGL detile hot path is missing" + missing=1 + fi + done + test "$missing" -eq 0 + echo "::notice::libdrmtap .so contract ok ($nsyms loader symbols, EGL detile present)" + + # The bridge generator is a reusable workflow, so this calls the stock one instead of duplicating it. + generate-bridge: + uses: ./.github/workflows/bridge.yml + + drm-deb: + name: unattended-wayland deb (verification build) + needs: generate-bridge + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: false + + - name: Checkout source code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + persist-credentials: false + + - name: Restore bridge files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bridge-artifact + path: ./ + + - name: Install prerequisites + shell: bash + run: | + sudo apt-get -y update + # Same list the stock linux job needs, plus the flutter desktop toolchain and the three + # libdrmtap build deps (libdrm and the mesa-specific EGL/GLES dev packages). + sudo apt-get install -y \ + clang cmake curl gcc git g++ ninja-build meson pkg-config \ + libpam0g-dev libasound2-dev libunwind-dev liblzma-dev \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libgtk-3-dev libpulse-dev libva-dev libvdpau-dev \ + libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxdo-dev libxfixes-dev nasm wget \ + libdrm-dev libegl1-mesa-dev libgles2-mesa-dev + + - name: Setup vcpkg with Github Actions binary cache + uses: lukka/run-vcpkg@b1a0dd252f06b9e25b3c022a9a03bd7a427fb6a2 # v11 + with: + vcpkgDirectory: /opt/artifacts/vcpkg + vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }} + + - name: Install vcpkg dependencies + shell: bash + run: $VCPKG_ROOT/vcpkg install --x-install-root="$VCPKG_ROOT/installed" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: x86_64-unknown-linux-gnu + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Setup flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Patch flutter + shell: bash + run: | + cd $(dirname $(dirname $(which flutter))) + # `[[ ... ]] && cmd` as the last line makes the STEP fail once FLUTTER_VERSION moves off + # the pinned value, because the failed test becomes the script's exit status. An explicit + # if/else skips instead. Reading the values from the environment rather than interpolating + # github expressions into the script also keeps this off zizmor's template-injection list. + # (spelled out in prose: a literal expression marker here, even in a comment, is parsed by + # actionlint and breaks workflow linting.) + if [[ "$FLUTTER_VERSION" == "3.24.5" ]]; then + git apply "$GITHUB_WORKSPACE/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff" + else + echo "::notice::flutter $FLUTTER_VERSION is not 3.24.5; skipping the dropdown patch" + fi + + - name: Build the unattended-wayland deb + shell: bash + run: | + set -euo pipefail + # The features have to be on the cargo line HERE, because the packaging line below passes + # --skip-cargo and never rebuilds: whatever this compiles is what ships. ASK build.py for + # the list rather than repeating it -- get_features() is the single definition of what + # these flags mean, and a hardcoded copy silently ships something other than what + # `build.py --drm` produces the moment that function changes. The flags must be the same + # on both lines for that to hold, so keep them in one variable. + DRM_BUILD_FLAGS=(--flutter --drm --hwcodec --unix-file-copy-paste) + FEATURES="$(python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --print-features)" + echo "features from build.py: $FEATURES" + # Assert rather than trust: an empty or error-shaped value would otherwise become a cargo + # line that builds a stock binary, which only the staged-binary marker check would catch. + # Match whole comma-separated TOKENS, one feature at a time. A substring test would depend + # on the order get_features happens to append them (failing a correct build the day they + # are reordered) and would also match a future feature that merely contains "drm", the same + # trap build.py avoids by splitting on commas rather than testing a substring. + for want in drm drm-wake; do + case ",$FEATURES," in + *",$want,"*) ;; + *) echo "::error::build.py --print-features returned no '$want' feature: $FEATURES"; exit 1 ;; + esac + done + cargo build --locked --lib --release --features "$FEATURES" + python3 ./build.py "${DRM_BUILD_FLAGS[@]}" --skip-cargo + + # build.py exits 0 on some inner failures, so assert the artifact instead of trusting the status, + # and assert the two things that make it the drm variant at all. + - name: Assert the deb is a real drm build + shell: bash + run: | + # Strict mode so the mid-script checks can fail the step (without it only the LAST + # command's status counts and the greps above it are decorative). + set -euo pipefail + # Glob into an array and assert the COUNT. `deb="$(ls ...)"` aborted on zero matches + # before its own `test -n` could report, and on several matches produced a multi-line + # value whose `mv` failed with something unrelated to the real problem. + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected exactly one rustdesk-unattended-wayland-*.deb, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + echo "::notice::built $deb ($(stat -c %s "$deb") bytes)" + # Pipe-free for the same reason as the .so contract step above (see the comment there: + # a producer feeding a grep that can exit early is a SIGPIPE reported as a failure under + # pipefail). `grep -E` without -q reads to EOF so these two happen to be safe, but the + # shape is the hazard and the next `-q` added here would inherit it silently. + contents="$(dpkg -c "$deb")" + if [[ ! "$contents" =~ usr/lib/rustdesk/libdrmtap\.so\.0\.[0-9]+\.[0-9]+ ]]; then + echo "::error::the deb does not contain a versioned libdrmtap.so.0.x.y" + exit 1 + fi + if [[ "$contents" != *"usr/lib/rustdesk/libdrmtap.so.0 ->"* ]]; then + echo "::error::the deb does not contain the libdrmtap.so.0 soname symlink" + exit 1 + fi + # The library alone does not make this a drm build: build.py stages it whenever --drm is + # passed, independently of what was compiled, and the deb name is what tells a user this + # is the consent-bypass variant. Assert the BINARY too, by the absolute dlopen path that + # only exists when the feature is compiled in -- otherwise a stock binary could ship + # under the unattended-wayland name with a library it can never reach. + rm -rf /tmp/debassert && dpkg-deb -R "$deb" /tmp/debassert + if ! grep -qF /usr/lib/rustdesk/libdrmtap.so.0 /tmp/debassert/usr/share/rustdesk/lib/librustdesk.so; then + echo "::error::the packaged librustdesk.so has no libdrmtap dlopen path; this is not a drm build" + exit 1 + fi + mv "$deb" "${deb%.deb}-x86_64.deb" + + # MEASURE the glibc floor rather than describing it. This job builds on the runner instead of the + # ubuntu18.04 container the stock release debs use, so the artifact only runs on a host at least + # as new as the runner -- and that number belongs in the artifact NAME, because a comment in this + # file is not visible to whoever downloads it from the Actions UI. + - name: Measure the deb glibc floor + id: floor + shell: bash + run: | + # Strict mode for the same reason as the assert step above. The floor extraction gets an + # explicit rescue so a no-match grep reaches the `test -n` reporter instead of dying as a + # bare pipeline failure. + set -euo pipefail + # Same nullglob array + count assertion as the assert step above, for the same two + # reasons: under set -e a zero-match `ls` aborts before anything can report WHY, and + # several matches make `deb` multi-line so dpkg-deb fails with an unrelated error. (This + # was the sibling left behind when that one was fixed.) + shopt -s nullglob + debs=(rustdesk-unattended-wayland-*-x86_64.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "::error::expected exactly one renamed deb to measure, found ${#debs[@]}: ${debs[*]-none}" + exit 1 + fi + deb="${debs[0]}" + rm -rf /tmp/debfloor && dpkg-deb -R "$deb" /tmp/debfloor + floor="$(objdump -T /tmp/debfloor/usr/share/rustdesk/lib/librustdesk.so \ + | grep -oE 'GLIBC_2\.[0-9]+' | sort -uV | tail -1 || true)" + test -n "$floor" + echo "floor=${floor#GLIBC_}" >> "$GITHUB_OUTPUT" + echo "::notice::deb requires ${floor} or newer (built on the runner, not the ubuntu18.04 release container)" + + # Verification artifact, deliberately NOT a release deliverable. The consent-free variant stays + # out of the published release either way; the name states the floor so nobody installs it on an + # older distro and hits a bare loader error. + - name: Upload the deb + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rustdesk-unattended-wayland-x86_64-verification-glibc${{ steps.floor.outputs.floor }}.deb + path: rustdesk-unattended-wayland-*-x86_64.deb diff --git a/.gitignore b/.gitignore index d2e09a906..f51a5b8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ examples/**/target/ vcpkg_installed flutter/lib/generated_plugin_registrant.dart libsciter.dylib -flutter/web/ \ No newline at end of file +flutter/web/ +# libdrmtap is cloned at build time by build.py (not a submodule) +/third_party/libdrmtap/ diff --git a/Cargo.toml b/Cargo.toml index a7b2aca77..2fac88c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,13 @@ default = ["use_dasp"] hwcodec = ["scrap/hwcodec"] vram = ["scrap/vram"] mediacodec = ["scrap/mediacodec"] +drm = ["scrap/drm"] +# The display wake, as its OWN compile gate on top of `drm`. Everything else in the drm backend +# READS (it captures a scanout); the wake WRITES, injecting one synthetic pointer event from the +# root service so a compositor that idle-disabled its outputs re-enables them. That is a different +# kind of operation and deserves a switch that can remove it from the binary entirely, without +# giving up DRM capture: `--features drm` builds the capture path with no wake code compiled in. +drm-wake = ["drm"] plugin_framework = [] linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"] unix-file-copy-paste = [ diff --git a/build.py b/build.py index 957961857..9ebcf0eba 100755 --- a/build.py +++ b/build.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 import os +import glob +import contextlib import pathlib import platform import zipfile import urllib.request import shutil import hashlib +import re +import subprocess import argparse import sys from pathlib import Path @@ -130,6 +134,19 @@ def make_parser(): action='store_true', help='Build with unix file copy paste feature' ) + parser.add_argument( + '--drm', + action='store_true', + help='Linux only: build the DRM/KMS capture backend (bundles libdrmtap.so, ' + 'dlopen-ed in-process by the root service). Off by default.' + ) + parser.add_argument( + '--print-features', + action='store_true', + help='Print the cargo feature list these flags select, and exit without building. For a ' + 'caller that runs its own cargo line and then packages with --skip-cargo: it can ask ' + 'for the list rather than repeat it, so the two cannot drift.' + ) parser.add_argument( '--skip-cargo', action='store_true', @@ -272,6 +289,24 @@ def external_resources(flutter, args, res_dir): shutil.copytree(f, f'{flutter_build_dir_2}{f.stem}') +def linux_packaging_branch(): + """Which packaging path `main()` will take on THIS host. + + MUST mirror the elif chain in main() (pacman / yum / zypper / else), and exists so `--drm` can + refuse a branch that is not drm-aware instead of silently producing a stock-named package with + the capture backend compiled in. Only the final `deb` branch reaches `build_flutter_deb`, which + is what bundles libdrmtap, renames the package, adds Conflicts/Provides and asserts the staged + binary really is a drm build. + """ + if os.path.isfile('/usr/bin/pacman'): + return 'pacman' + if os.path.isfile('/usr/bin/yum'): + return 'yum' + if os.path.isfile('/usr/bin/zypper'): + return 'zypper' + return 'deb' + + def get_features(args): features = ['inline'] if not args.flutter else [] if args.hwcodec: @@ -282,6 +317,30 @@ def get_features(args): features.append('flutter') if args.unix_file_copy_paste: features.append('unix-file-copy-paste') + if args.drm: + # Say so rather than quietly handing back a stock build: the backend is Linux-only, so on + # any other host the flag cannot be honoured and the resulting binary would look like a + # DRM build without being one. + if windows or osx: + raise Exception('--drm is Linux only') + # And only on the deb branch. The other three Linux paths (pacman/yum/zypper) package + # straight from `target/release` without bundling libdrmtap, without the rename, without + # Conflicts/Provides and without assert_staged_binary_is_drm() -- so they would emit a + # package NAMED `rustdesk` carrying the consent-bypass backend and the root-side uinput + # injection. The separate package name is the informed consent this feature rests on (see + # docs/DRM_CAPTURE_SECURITY.md), so refuse rather than ship a stock-named build of it. + branch = linux_packaging_branch() + if branch != 'deb': + raise Exception( + f'--drm is only supported on the deb packaging path; this host would package via ' + f'{branch}, which cannot bundle libdrmtap or name the package distinctly') + features.append('drm') + # The display wake is its own compile gate on top of `drm`, and the unattended package is + # exactly where it belongs: that variant exists to reach a machine nobody is sitting at, + # and a machine whose screen went dark is the case it is for. Dropping `drm-wake` from + # this line builds the same capture backend with no wake code in the binary at all. + # It is ALSO switchable at runtime; see OPTION_ENABLE_DRM_DISPLAY_WAKE. + features.append('drm-wake') if osx: if args.screencapturekit: features.append('screencapturekit') @@ -316,6 +375,271 @@ def ffi_bindgen_function_refactor(): 'sed -i "s/ffi.NativeFunction= floor + if not accepted: + raise Exception( + f'the libdrmtap being packaged is {so_ver[0]}.{so_ver[1]}.{so_ver[2]}, which the ' + f'runtime loader would REFUSE: drmtap_dl.rs accepts exactly major {major}, minor ' + f'{minor}, patch >= {floor[1]}. Shipping it produces a deb whose DRM capture can never ' + 'start. Move the build pin and the gate together, or fix whichever one is wrong.') + print(f'[drm] libdrmtap {so_ver[0]}.{so_ver[1]}.{so_ver[2]} satisfies the runtime ABI gate ' + f'(major {major}, minor {minor}, patch >= {floor[1]})') + + +def stage_libdrmtap_into_deb(so_path): + # Put the built libdrmtap object plus its soname symlink into the staged deb. Only the soname + # symlink is needed: libdrmtap is resolved by ABSOLUTE path (/usr/lib/rustdesk/libdrmtap.so.0) at + # the in-process dlopen site (drmtap_dl.rs), so the deb does NOT drop /usr/lib/rustdesk into the + # system-wide /etc/ld.so.conf.d search path, which would let this private library shadow a system + # library for every binary on the host (Debian Policy 10.2 forbids that). No ld.so.conf.d drop-in + # and no ldconfig trigger are shipped, so the stock postinst is used unchanged. + assert_so_satisfies_the_runtime_abi_gate(so_path) + so_basename = os.path.basename(so_path) + system2('mkdir -p tmpdeb/usr/lib/rustdesk') + # Quoted: so_path comes from the repo root or from DRMTAP_PREBUILT_DIR, either of which can + # contain a space, and an unquoted interpolation would split the argument and fail obscurely. + system2(f'cp "{so_path}" tmpdeb/usr/lib/rustdesk/') + system2(f'ln -sf "{so_basename}" tmpdeb/usr/lib/rustdesk/libdrmtap.so.0') + + +def retarget_control_to_drm_variant(): + # Rewrite the control file that generate_control_file just produced, instead of parameterizing that + # function: the stock packaging path stays exactly as upstream wrote it, and everything specific to + # this variant lives here. The variant installs the same files as the stock package, so it must + # conflict with and replace it: you install one or the other, never both. It also needs libdrmtap's + # own runtime deps, which the stock package has no reason to carry. + path = '../res/DEBIAN/control' + with open(path) as f: + lines = f.readlines() + out = [] + for line in lines: + if line.startswith('Package: rustdesk'): + out.append(f'Package: {DRM_PACKAGE_NAME}\n') + out.append('Conflicts: rustdesk\nReplaces: rustdesk\nProvides: rustdesk\n') + elif line.startswith('Depends:'): + out.append(line.rstrip('\n') + ', libdrm2, libegl1, libgles2\n') + else: + out.append(line) + body = ''.join(out) + # Fail loudly rather than silently shipping a package that says `rustdesk`: a stock control file + # that stopped matching either anchor would otherwise produce a variant deb wearing the stock name. + if f'Package: {DRM_PACKAGE_NAME}\n' not in body or 'libegl1' not in body: + raise Exception(f'could not retarget {path} to the drm variant; upstream control layout changed') + with open(path, 'w') as f: + f.write(body) + + def build_flutter_deb(version, features): if not skip_cargo: system2(f'cargo build --locked --features {features} --lib --release') @@ -352,9 +676,22 @@ def build_flutter_deb(version, features): 'cp ../res/pam.d/rustdesk.debian tmpdeb/etc/pam.d/rustdesk') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Bundle libdrmtap.so only when this build actually enabled the `drm` feature, so stock packages + # stay exactly what they were. The root service dlopens it in-process by absolute path. + # `features` is the comma-joined string, so split it: a bare substring test would also match any + # future feature merely containing "drm" (drm-lease, vaapi-drm) and rename the deb to the + # consent-bypass variant without --drm ever being passed. + ships_so = 'drm' in features.split(',') + if ships_so: + # Same artifact assertion as the --package path. Under --skip-cargo nothing here rebuilt the + # binary, so `features` says what was ASKED for while the staged bundle can be anything. + assert_staged_binary_is_drm() + stage_libdrmtap_into_deb(build_libdrmtap_so()) system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) + if ships_so: + retarget_control_to_drm_variant() system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -362,10 +699,68 @@ def build_flutter_deb(version, features): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + if ships_so: + # Named apart from the stock package so installing the consent-free variant is a deliberate act. + os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb') os.chdir("..") -def build_deb_from_folder(version, binary_folder): +DRMTAP_DLOPEN_MARKER = b'/usr/lib/rustdesk/libdrmtap.so.0' +# Present only when `drm-wake` is compiled in: the runtime option constant is itself +# #[cfg(feature = "drm-wake")] (src/ipc/drm.rs). The dlopen marker above cannot stand in for it - +# `--features drm` alone produces a binary that carries the dlopen path and NO wake code, and that +# is exactly the deb this assertion is here to refuse. +DRMTAP_WAKE_MARKER = b'enable-drm-display-wake' + + +def _carries_drmtap_marker(path, marker=DRMTAP_DLOPEN_MARKER): + # Chunked, with an overlap of len(marker)-1 so the marker cannot be missed at a chunk boundary: + # librustdesk.so is ~45 MB and there is no reason to hold it all in memory, and the `with` + # closes deterministically instead of relying on refcounting. + with open(path, 'rb') as f: + tail = b'' + while True: + chunk = f.read(1 << 20) + if not chunk: + return False + if marker in tail + chunk: + return True + tail = chunk[-(len(marker) - 1):] + + +def assert_staged_binary_is_drm(): + """The staged BINARY must really be a drm build before it is named the unattended-wayland + variant. That package conflicts with and replaces the stock one, so shipping a stock binary + under that name produces something that can never capture and cannot be installed alongside + what it replaced. The marker is the absolute dlopen path from drmtap_dl.rs, present only when + the feature is compiled in -- assert what was produced, not what was asked for. + + Called from BOTH packaging paths. It used to guard only one of them, and `--skip-cargo` (which + is how CI packages) reaches the other, where nothing had rebuilt the binary at all. + """ + binaries = [p for p in glob.glob('tmpdeb/usr/share/rustdesk/lib/librustdesk.so') + + glob.glob('tmpdeb/usr/share/rustdesk/rustdesk') if os.path.isfile(p)] + if not any(_carries_drmtap_marker(p) for p in binaries): + raise Exception( + f'--drm was requested but the staged bundle does not look like a drm build (no ' + f'{DRMTAP_DLOPEN_MARKER.decode()} dlopen path in {binaries or "any staged binary"}); ' + 'refusing to package it as the unattended-wayland variant, which conflicts with and ' + 'replaces the stock package but could never capture') + # And the WAKE half. `--drm` enables `drm-wake` too (see get_features), and the deb is named and + # documented as the variant that can reach a machine whose screen has gone dark. The dlopen + # marker above does not distinguish them: `--features drm` alone carries it and has no wake code + # at all. Asserting only the first half is how a deb can be named for a feature it does not have. + if not any(_carries_drmtap_marker(p, DRMTAP_WAKE_MARKER) for p in binaries): + raise Exception( + f'--drm was requested but the staged binary has no {DRMTAP_WAKE_MARKER.decode()} ' + f'marker in {binaries or "any staged binary"}, so it was built without `drm-wake`; ' + 'refusing to package it as the unattended-wayland variant, which is named and ' + 'documented as the build that can wake an idle-disabled display. If this fired under ' + '--skip-cargo, the cargo line that produced the bundle is missing the feature: ' + '--features ...,drm,drm-wake') + + +def build_deb_from_folder(version, binary_folder, want_drm=False): os.chdir('flutter') system2('mkdir -p tmpdeb/usr/bin/') system2('mkdir -p tmpdeb/usr/share/rustdesk') @@ -389,9 +784,53 @@ def build_deb_from_folder(version, binary_folder): 'cp ../res/rustdesk-link.desktop tmpdeb/usr/share/applications/rustdesk-link.desktop') system2( "echo \"#!/bin/sh\" >> tmpdeb/usr/share/rustdesk/files/polkit && chmod a+x tmpdeb/usr/share/rustdesk/files/polkit") + # Where the capture library comes from for a `--package --drm` build. Two shapes are + # supported, because two exist in practice: a bundle that already carries libdrmtap.so.0.* + # (someone staged it, e.g. a CI artifact), and a plain bundle, which is what every build path + # here actually produces -- the flutter deb builds the library straight into the staged deb, so + # nothing ever puts it inside the bundle folder. Demanding it in the bundle made this flag + # combination impossible to satisfy. + bundled_glob = glob.glob('tmpdeb/usr/share/rustdesk/libdrmtap.so.0.*') + bundle_carries_so = any(os.path.isfile(p) and not os.path.islink(p) for p in bundled_glob) + # The variant must be decided by the EXPLICIT --drm request, not merely by what happens to be + # staged: a bundle that carries the .so must NOT be shipped as the consent-bypass variant when + # --drm was never passed. + if bundle_carries_so and not want_drm: + raise Exception( + 'the staged bundle carries libdrmtap.so.0.* but --drm was not passed; refusing ' + 'to silently ship the consent-bypass unattended-wayland variant (pass --drm to ' + 'build it deliberately)') + if want_drm: + # Whichever shape we are in, the staged BINARY must really be a drm build. This is the + # property the old presence-of-the-.so test stood in for, badly: a stock binary packaged as + # the unattended-wayland variant would carry the consent-bypass name, conflict with and + # replace the stock package, and never be able to capture. The marker is the absolute + # dlopen path from drmtap_dl.rs, present only when the feature is compiled in -- the same + # kind of artifact assertion as _assert_so_has_egl, and for the same reason: assert what + # was produced, not what was asked for. + assert_staged_binary_is_drm() + if bundle_carries_so: + so = _single_real_so(bundled_glob, 'the staged --drm bundle') + # The THIRD artifact source, and the last one that was missing the check: --package + # takes the .so straight out of a bundle somebody else produced, so it has the same + # exposure as DRMTAP_PREBUILT_DIR (see the comment on that branch). A CPU-only stub + # would ship, the loader would accept it, and capture would degrade to PipeWire + # without a word. + _assert_so_has_egl(so) + stage_libdrmtap_into_deb(so) + system2(f'rm -f "{so}"') + system2('rm -f tmpdeb/usr/share/rustdesk/libdrmtap.so tmpdeb/usr/share/rustdesk/libdrmtap.so.0') + else: + # Build it here, exactly as the flutter deb path does (build_libdrmtap_so asserts the + # EGL backend itself). The library is independent of the staged binary. + stage_libdrmtap_into_deb(build_libdrmtap_so()) system2('mkdir -p tmpdeb/DEBIAN') generate_control_file(version) + # Keyed on the EXPLICIT request, not on what happened to be staged: by here a --drm build has + # its library in tmpdeb whichever of the two shapes it came from. + if want_drm: + retarget_control_to_drm_variant() system2('cp -a ../res/DEBIAN/* tmpdeb/DEBIAN/') md5_file_folder("tmpdeb/") system2('dpkg-deb -b tmpdeb rustdesk.deb;') @@ -399,6 +838,8 @@ def build_deb_from_folder(version, binary_folder): system2('/bin/rm -rf tmpdeb/') system2('/bin/rm -rf ../res/DEBIAN/control') os.rename('rustdesk.deb', '../rustdesk-%s.deb' % version) + if want_drm: + os.rename('../rustdesk-%s.deb' % version, f'../{DRM_PACKAGE_NAME}-{version}.deb') os.chdir("..") @@ -473,6 +914,19 @@ def main(): parser = make_parser() args = parser.parse_args() + # Before anything with a side effect: this is a query, and a caller uses it to build the very + # binary it will then package. `get_features` stays the single definition of what a flag + # combination means; a caller that hardcodes the list instead is one edit away from compiling + # something other than what it ships. + if args.print_features: + # stdout carries the list and nothing else, so a caller can use it directly in a command + # substitution. `get_features` prints a human-readable line of its own; send that to stderr + # for this call rather than silencing it, which would change what every other path prints. + with contextlib.redirect_stdout(sys.stderr): + feats = ','.join(get_features(args)) + print(feats) + return + if os.path.exists(exe_path): os.unlink(exe_path) if os.path.isfile('/usr/bin/pacman'): @@ -488,7 +942,7 @@ def main(): portable = args.portable package = args.package if package: - build_deb_from_folder(version, package) + build_deb_from_folder(version, package, args.drm) return res_dir = 'resources' external_resources(flutter, args, res_dir) diff --git a/docs/DRM_CAPTURE_SECURITY.md b/docs/DRM_CAPTURE_SECURITY.md new file mode 100644 index 000000000..9f0c98600 --- /dev/null +++ b/docs/DRM_CAPTURE_SECURITY.md @@ -0,0 +1,255 @@ +# DRM/KMS capture — security model & threat model + +The optional `drm` feature adds a Linux capture backend that reads the active +scanout directly from DRM/KMS, **bypassing the xdg-desktop-portal consent +dialog**. It exists for unattended / login-screen / Wayland scenarios where the +portal prompt is not acceptable. Because it bypasses consent, treat it as a +**privileged, opt-in host-mode feature**, not a normal Wayland capture backend. + +## How it works + +Reading the active scanout needs `CAP_SYS_ADMIN` (to map other clients' +framebuffers). RustDesk's root `--service` already runs with `CAP_SYS_ADMIN`, so +the `drm` feature does the read **in-process in that root service**: it +`dlopen`s `libdrmtap.so` and calls it in direct mode — no privileged child, no +`setcap` helper. On the **default (split) path** the root service does not touch +pixels: it exports the active scanout as a DMA-BUF and passes just that +**read-only** fd to the unprivileged user `--server` over a dedicated +service-scoped IPC channel (`_drm`) via `SCM_RIGHTS`. The `--server` keeps an +**import-once EGLImage cache** (keyed on the buffer, so a given scanout buffer is +imported once and re-imports are elided), detiles/converts it to linear RGBA in +its own unprivileged address space, and feeds the encoder — so **on that path** +the root service never copies scanout pixels and never loads libEGL/libGLESv2 +(measured on the running service, see *Auditing*). Only the **CPU fallback path** +(used when the seat/driver cannot produce a transferable DMA-BUF, or the consumer +has no render node of its own, see *When the CPU fallback is chosen* below) +copies the scanout to packed BGRA inside the root service and streams those bytes +over `_drm`. + +**The no-GL property is a property of the default path, not of the process.** Be +precise about it, because the CPU fallback is the whole reason the split exists: +converting a scanout in-process means decoding whatever layout it is in, and a +tiled scanout (the common case on modern Intel and AMD) can only be decoded +through the GPU. `drmtap_grab_mapped` therefore reaches libdrmtap's auto-process +step, which lazily `dlopen`s libEGL/libGLESv2 **in the calling process** when the +scanout needs a GPU detile. So a host that has fallen back to the CPU path can +map the GL stack inside the `CAP_SYS_ADMIN` service. What the design does about +that is bound the cases: the fallback is entered only for the three reasons +listed below, never as a silent degradation of the split path (the loader refuses +a `libdrmtap` that cannot export the fd at all, precisely so "old library" cannot +turn into "convert in the privileged process"), and a linear or CPU-mappable +scanout is converted without touching GL. Every host measured here runs the split +path with zero GL regions in the service; a CPU-fallback host is a different +posture and is worth measuring separately. This mirrors the Windows +`portable_service` split (a privileged process captures, an unprivileged one +presents) but reuses RustDesk's own hardened IPC. + +- `libdrmtap.so` is loaded through a small `dlopen` loader (`drmtap_dl`); if the + library or one of its runtime deps is missing the load fails cleanly and the + caller falls back to the PipeWire/portal path. +- The loader also **refuses a library that cannot do the split** — and, more + broadly, any version outside the vetted window. Accepted is exactly the pinned + minor with a patch floor (currently `0.5.x`, `x >= 0`): an older minor is + refused (`0.4.x` included, even though it carries the split entry points, because + it decodes a padded scanout pitch at the wrong stride), and a **newer minor is + refused too** (`0.6.x` onward), because the loader mirrors C struct layouts that are only + field-by-field verified against the pinned minor; widening the window is a + deliberate act done together with re-verifying the layouts and moving the + build pin. Independently of the version report, a library that does not + actually export + `drmtap_grab_desc` / `drmtap_open_render` / `drmtap_convert_dmabuf` (a stale or + pre-release build) is refused as well. The only way to capture with such a library is the + in-process convert, which in the root service means loading the vendor GL stack + there, so it is refused and the caller falls back to PipeWire/portal. The + privileged process therefore never loads GL because of which file happened to + be on the load path; the CPU fallback below is entered only for a fact about + the seat or the consumer. +- The reader restricts the device it opens to a realpath under `/dev/dri/` + (`drm_reader.rs`); RustDesk always runs libdrmtap in direct in-process mode + (`helper_path` is `NULL`). **No `drmtap-helper` binary is built, shipped, or + installed by this package**: there is no `setcap`, no capability-bearing file, + and no capture group in this deployment. Being precise about what that does + and does not guarantee: an empty `helper_path` is not by itself a "helper + disabled" switch in the C. `find_helper` (`privilege_helper.c`) searches six + hardcoded paths, one of which is `/usr/lib/rustdesk/drmtap-helper`, the + directory this package installs into, and `fork`/`exec`s the first executable + it finds if the direct export ever returns `EACCES`/`EPERM`. Here that path is + unreachable for two independent reasons: the root service holds + `CAP_SYS_ADMIN` so the direct export succeeds, and the package builds only the + shared library, so no helper exists at any of those paths. They are all + root-writable-only, so a helper appearing there would not be an escalation + either, but the honest statement is "a privileged child is spawned only if a + helper binary exists at one of those fixed root-owned paths, and this package + never installs one", not "never". +- The `_drm` socket lives beside the hardened `_service` socket + (`/tmp/-service/ipc_drm`). It is `0666` so the unprivileged `--server` + can connect, but every accepted peer is authorized in `handle_drm_conn` + (`authorize_service_scoped_ipc_connection`: peer must be root or the active + session uid, with a `/proc//exe` identity match). Connectable is not + authorized. + +## Threat model + +- **Consent bypass.** This mode does not show the portal "select what to share" + prompt. On a misconfigured install it could expose the login screen, the lock + screen, or another local user's graphical session. +- **The scanout parse runs in the root service.** Moving the read in-process + removes the old `setcap` helper and its world-exec attack surface. On the + **default (split) path** the root service does only a **metadata-only** parse + of the scanout descriptor and exports the DMA-BUF fd; the untrusted-framebuffer + detile / pixel-format conversion runs in the **unprivileged `--server`**, + outside `CAP_SYS_ADMIN`. Export-side validation is therefore metadata-only — + geometry bounded to `<= MAX_DIM` (16384) and `num_planes` in `1..=4` + (`drm_reader.rs` `grab_desc`); there is **no fourcc gate** on the export side, + because the format check is delegated to the unprivileged converter, which + handles every format `libdrmtap` supports (XRGB/ARGB8888, 10-bit XR30/AR30, + HDR, CCS-compressed). The exported fd is **read-only**: `libdrmtap` exports the + DMA-BUF via `drmPrimeHandleToFD` with `DRM_RDWR` dropped (`O_RDONLY`), and + `drm_reader` `dup()`s it — which shares the same open file description and so + preserves that access mode — so the unprivileged consumer can map the scanout + for reading but never write into the live framebuffer. On the **CPU fallback + path** the pixel-format conversion / detile instead runs inside the + `CAP_SYS_ADMIN` service without a seccomp cage; there the frame copy has + format / stride / geometry and integer-overflow guards (`drm_reader.rs` + `grab`), and non-32bpp scanouts are rejected before the copy. The device is + realpath-gated to `/dev/dri/` on both paths. +- **`_drm` is a screen-content channel.** It is authorized per connection (see + above); without that authz any local process could read the screen. Authorization + is also **re-checked on every frame**, not only at accept, because DRM/KMS + capture is not session-scoped: it grabs the physical scanout of a CRTC no matter + which session owns the display. So when the active session changes -- a user + logging in at a greeter -- the greeter's `_drm` stream is CLOSED rather than + continued (`drm: _drm peer no longer matches the active session`; observed with + peer_uid=60578 against active_uid=1000, and the greeter's uinput channel goes + with it). That is what stops an outgoing greeter process from capturing the + logged-in user's screen. The cost is a reconnect, not the session: the client + re-establishes itself against the new session's `--server` on its own in about + 2.5 s (~3.6 s of dark screen, measured 2026-07-31). On the + **default (split) path** the channel carries the scanout DMA-BUF fd, passed to + the unprivileged `--server` over `SCM_RIGHTS` as a **read-only** descriptor + (the `--server` holds an import-once EGLImage cache, so a given scanout buffer + is imported once and re-imports are elided); the peer can map the scanout for + reading but cannot write it. The **CPU fallback path** instead carries plain + packed-BGRA bytes over the same authorized socket (no fd passing, no shared + memory). +- **When the CPU fallback is chosen.** The split path is the default; the + consumer asks the service for the CPU-converted frame in two cases: no render + node can be opened for this seat, or a previous convert on this display + already failed. A third case is a **multi-GPU safety fallback**: if + the service could not name the render node of the GPU that exports the scanout + (an older `libdrmtap` without `drmtap_render_node`) and the host has more than + one render node, the consumer refuses to guess one, because importing a scanout + on a device that did not export it can succeed and return corrupted pixels + rather than fail. The conversion then happens in the service, on the device it + already has open, so it is correct by construction. Hosts with a single render + node have nothing to pick wrong and keep the DMA-BUF fast path. +- **The display wake injects synthetic input from the root service.** It is + compiled in only with the `drm-wake` feature, which `build.py --drm` adds on + top of `drm`, and it can be switched off at runtime with + `enable-drm-display-wake=N`. Building with `--features drm` alone leaves no + wake code in the binary at all, so an operator auditing the deb can answer + "is the injection path even present here?" from the artifact. A + compositor that idles long enough DISABLES a connector, leaving no scanout for + any backend, so on a `_drm` handshake that finds a CONNECTED display with no + CRTC the service emits one synthetic pointer round trip over `/dev/uinput` to + make the compositor re-enable it. The virtual device **declares** two relative + axes and `BTN_LEFT`, because libinput classifies a device before it will treat + its events as pointer activity at all and a single axis with no buttons is + ignored outright (measured three ways on the same idle machine). What it + actually **emits** is `+1` then `-1` on one axis: net-zero displacement, no + button press, no key events. This is deliberate input injection by privileged + code, so its bounds are worth stating precisely: + - it can only be reached through an **already-authorized** `_drm` connection + (same per-connection authz as every other use of the channel), so it grants + nothing to a local attacker that the channel itself does not; + - it runs in the root service because that is the only place it can: + `/dev/uinput` is root-only here, and a modeset of our own is not an option + since the compositor holds DRM master (the sysfs `dpms` attribute is + read-only). Session-bus routes (`org.gnome.ScreenSaver`) authenticate by + uid, refuse root, and are desktop-specific; + - the trigger is narrow — a connected-but-undriven connector, not "no + frames" — and connectors a wake demonstrably cannot bring back are + remembered by connector identity and stop triggering. That memory is + per-connector rather than global, so a permanently dark connector cannot + suppress the wake for a different panel, and it drops any entry later seen + scanning out. Note what that recovery rule does and does not give you: it + clears the moment the display is driven **by anything**, but nothing else + retries, so a connector latched after a wake that failed for a transient + reason stays latched until that display comes back some other way — on an + unattended host, typically not until the service restarts. It is a + deliberate trade against waking on every connection forever for a display + that is never coming; + - it is rate limited to **one wake per 20 s process-wide** with exactly one + concurrent winner (compare-exchange claim), so a reconnect storm cannot + become an input-injection storm. That bounds the injection RATE. It does + not bound how long a screen stays lit, and neither does the one-shot + property below: 20 s is shorter than every idle period measured below, so a + remote peer that reconnects in a loop can have the panel relit after each + idle-off. What that peer gains is a lit panel on a machine whose screen it + is already authorized to watch: it is visible to someone standing there, + not additional access; + - the wake is **one-shot: it resets the compositor's idle timer, it does not + hold the display on**. If nothing else keeps the session awake, the connector + idles off again one full idle period later -- measured 2026-07-31: 30.3 s at + a GDM greeter, 70.3 s in a user session with `idle-delay=60`. Keeping a + screen lit for the length of a session is the job of RustDesk's existing + keep-awake inhibitor, not of this wake, which only recovers a connector that + is *already* dark; + - the uinput device is created and destroyed around the emit — nothing + persists in the input stack between wakes; + - without `/dev/uinput` the wake is skipped and latched off. Such a session + was already view-only (input injection on Wayland needs uinput too), so + this adds no new failure mode. + +## Deployment + +- **Off by default.** The `drm` feature is **not** in the default feature set and + is **not** enabled in standard release packages; the drm-off build is + byte-identical to upstream. Build it explicitly with + `python3 build.py --flutter --drm` (Linux only). +- **Separate opt-in package.** A `--drm` build ships as a distinctly named + `rustdesk-unattended-wayland` package (Conflicts/Replaces/**Provides** `rustdesk` -- + `Provides` is what lets a third-party package that depends on `rustdesk` be satisfied by the + consent-free variant, so it belongs in an audit of this metadata), so + enabling consent-free capture is an explicit install choice. +- **Bundled library, no capabilities.** The package installs the versioned + `libdrmtap.so.0..` plus a `libdrmtap.so.0` soname symlink under + `/usr/lib/rustdesk/`, and the in-process `dlopen` names that absolute path + (`/usr/lib/rustdesk/libdrmtap.so.0`). The package deliberately does **not** + register the directory with the dynamic linker: no + `/etc/ld.so.conf.d/` drop-in and no `ldconfig` trigger are shipped, so a + private library cannot shadow a system one for unrelated binaries + (Debian Policy 10.2). The bare-soname lookups remain only as a fallback for a + development build reached through `LD_LIBRARY_PATH`. + + There is no `setcap`, no `rustdesk-capture` group, and no privileged binary: + the capture runs inside the root `--service`, which already holds the + capability it needs. Hosts without `/dev/dri` access (or where the library + fails to load) transparently fall back to the PipeWire/portal path. +- **Minimum libdrm: 2.4.95.** `libdrmtap` needs the DRM `GetFB2` framebuffer API, which + landed in libdrm 2.4.95. Ubuntu 18.04 is the oldest distribution worth naming here, and it + straddles the floor: base bionic shipped 2.4.91, below it, while the updates/HWE stack + (2.4.101) is above — so read this as "18.04 with updates, or anything newer", not as + "any 18.04". That is an API statement, not a binary-compatibility one: + the `rustdesk-unattended-wayland` deb in this repo's CI is built on an ubuntu-24.04 runner, so the + shipped binaries carry that build host's glibc floor. Running on an older distribution means + building the deb there (or in a matching container), which the libdrm floor above permits. + Capture also requires an active KMS scanout (a Wayland/KMS session with a display + on); on hosts where the compositor drives the display outside DRM/KMS (e.g. the proprietary NVIDIA + X11 stack) there is no capturable CRTC and the path falls back to PipeWire/portal. +- **Recommended for** single-user, physically-controlled, or unattended hosts. + +## Auditing + +```bash +# the bundled capture library and its soname symlink — no capabilities are set on either +ls -l /usr/lib/rustdesk/libdrmtap.so.0* +# the dlopen names the symlink by absolute path, so what matters is where the symlink points: +readlink /usr/lib/rustdesk/libdrmtap.so.0 # expect: the versioned object shipped by the package +# and there should be no other object left beside it (a leftover is not loaded on its own, but it +# is what a stray ldconfig over this directory would repoint the symlink to): +ls /usr/lib/rustdesk/libdrmtap.so.0.* # expect: exactly one versioned object +ls /etc/ld.so.conf.d/ | grep -i rustdesk # expect: no output (none is shipped) +# confirm no privileged helper is present (there should be none) +getcap -r /usr/lib/rustdesk 2>/dev/null # expect: no output +``` diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 0af7dfe0f..da056b46d 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -11,6 +11,16 @@ edition = "2018" [features] wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] +# `drm` is a pure runtime-dlopen backend: rustdesk loads `libdrmtap.so.0` at runtime (`drmtap_dl.rs`) +# and NEVER link-time links it, so the graceful PipeWire fallback when the .so or EGL is absent is +# preserved and the drm build pulls in no libdrm/seccomp/cap/EGL link-time deps. The .so is pinned by +# `DRMTAP_SHA` in build.py, which fetches that exact commit (libdrmtap v0.5.2). We deliberately do +# NOT depend on the `libdrmtap-sys` crate: its build.rs statically compiles the whole libdrmtap C tree +# and a CAP_SYS_ADMIN helper and emits `-ldrm -lseccomp -lcap`, which would defeat the dlopen model. +# Depends on `wayland`: the three drm modules live inside the `#[cfg(feature = "wayland")]` arm of +# common/mod.rs, so `scrap/drm` on its own would compile nothing. The root crate happens to always +# enable `scrap/wayland`, which is what hid this. +drm = ["wayland"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] diff --git a/libs/scrap/src/common/drm_reader.rs b/libs/scrap/src/common/drm_reader.rs new file mode 100644 index 000000000..3d19c6c41 --- /dev/null +++ b/libs/scrap/src/common/drm_reader.rs @@ -0,0 +1,477 @@ +// Service-side DRM/KMS read engine, in the ROOT `--service`: libdrmtap reads the scanout in-process (direct mode). The DRM_DEVICE env is not consulted here. + +use super::drmtap_dl::{ + self, drmtap_config, drmtap_ctx, drmtap_cursor_info, drmtap_device, drmtap_display, + drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib, +}; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::{FromRawFd, OwnedFd}; + +// Trust-boundary limits and formats `drm_render` (the unprivileged converter) imports: two copies that drift apart would weaken one side. +// 16384 covers 8K+ with headroom; anything larger is rejected as a bogus/hostile geometry. +pub(crate) const MAX_DIM: u32 = 16384; +// 256 MiB covers an 8K BGRA frame (7680x4320x4 ~= 127 MiB) with margin. +pub(crate) const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024; +// XRGB/ARGB are little-endian B,G,R,{X,A} in memory == `Pixfmt::BGRA`; XBGR/ABGR are R,G,B,{X,A} == `Pixfmt::RGBA`. +pub(crate) const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258; // 'XR24' +pub(crate) const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241; // 'AR24' +pub(crate) const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258; // 'XB24' +pub(crate) const DRM_FORMAT_ABGR8888: u32 = 0x3432_4241; // 'AB24' + +/// Cursor id published when the plane reports the cursor hidden, so the id changes and, where the DRM cursor is authoritative, the client drops the last shape. +pub const HIDDEN_CURSOR_ID: u64 = u64::MAX; + +pub struct CursorSnapshot { + pub id: u64, + pub width: u32, + pub height: u32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +/// One enumerated DRM display, physical geometry only (the server overlays the Wayland logical origin/scale where it can match one). +pub struct DisplaySnapshot { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, +} + +pub struct DrmDevice { + pub path: String, + /// Render node, or empty if this device has none. + pub render_node: String, + pub display_count: u32, +} + +/// Copy a fixed C char array into a `String`, stopping at the first NUL WITHIN the array, so a +/// field libdrmtap failed to terminate cannot read past it. +fn cstr_field(buf: &[std::os::raw::c_char]) -> String { + // SAFETY: c_char and u8 share size/alignment; the slice is the exact length of `buf`. + let bytes: &[u8] = + unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) }; + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +/// Enumerate every DRM device with KMS resources. `None` = unavailable, too old, or failed (the caller then scans /dev/dri/card* itself); empty `Vec` = none found. +pub fn list_devices() -> Option> { + let lib = drmtap_dl::get()?; + let f = lib.list_devices?; + const MAX: usize = 16; + let mut raw: [drmtap_device; MAX] = unsafe { std::mem::zeroed() }; + // SAFETY: `raw` is MAX valid, zeroed drmtap_device slots; the call fills up to MAX and returns the count. + let n = unsafe { f(raw.as_mut_ptr(), MAX as std::os::raw::c_int) }; + if n < 0 { + log::warn!("drmtap_list_devices failed ({n}); using single-device auto-detect"); + return None; + } + let n = (n as usize).min(MAX); + Some( + raw[..n] + .iter() + .map(|d| DrmDevice { + path: cstr_field(&d.path), + render_node: cstr_field(&d.render_node), + display_count: d.display_count, + }) + .collect(), + ) +} + +/// The CANONICAL path, when `path` canonicalizes to a node directly under /dev/dri/, else `None`. +/// Callers must open the value returned: opening the original re-resolves every symlink component after the check. +pub(super) fn device_under_dev_dri(path: &str) -> Option { + let p = std::fs::canonicalize(path).ok()?; + if p.parent() == Some(std::path::Path::new("/dev/dri")) { + Some(p) + } else { + None + } +} + +/// An open DRM read context. Not Send/Sync deliberately (the raw ctx is used on one thread). +pub struct DrmReader { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, + buf: Vec, +} + +impl DrmReader { + /// Open the DRM device. `device = None` auto-detects, `Some(path)` is realpath-gated to /dev/dri/. `crtc_id = 0` auto-selects the first active CRTC. + pub fn open(device: Option<&str>, crtc_id: u32) -> Option { + let lib = drmtap_dl::get()?; + let device_cstr = match device { + None => None, + Some(d) => { + let Some(canonical) = device_under_dev_dri(d) else { + log::warn!("DRM device {d:?} is not under /dev/dri; refusing to open"); + return None; + }; + match canonical.to_str().and_then(|s| CString::new(s).ok()) { + Some(c) => Some(c), + None => return None, + } + } + }; + let cfg = drmtap_config { + device_path: device_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()), + crtc_id, + helper_path: std::ptr::null(), + debug: 0, + }; + // SAFETY: cfg is a valid struct; device_cstr outlives this call. + let ctx = unsafe { (lib.open)(&cfg) }; + drop(device_cstr); + if ctx.is_null() { + log::info!("drmtap_open failed; DRM capture unavailable"); + return None; + } + Some(DrmReader { + lib, + ctx, + buf: Vec::new(), + }) + } + + /// Grab one frame, tightly packed as BGRA (`w*4*h` bytes), into the internal buffer; valid until the next grab. + pub fn grab(&mut self) -> io::Result<(&[u8], usize, usize)> { + // SAFETY: ctx is valid; frame is zeroed before the call. The frame is released on every return path that OWNS one: a failing + // `drmtap_grab_mapped` leaves nothing to release, and releasing anyway would be a double free. + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = (self.lib.grab_mapped)(self.ctx, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_mapped failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // The row copy reads w*4 bytes from a source only stride*height bytes: reject sub-32bpp / insane geometry to avoid an OOB read. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + log::warn!( + "DRM scanout not 32-bit BGRA-compatible ({w}x{h} stride {stride} fourcc {:#010x}); falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + // XBGR8888 passes the stride check but, labeled BGRA downstream, would ship red and blue swapped; a zero fourcc falls through to the stride invariant (kept for libdrmtap builds that do not set it). + if frame.format != 0 + && frame.format != DRM_FORMAT_XRGB8888 + && frame.format != DRM_FORMAT_ARGB8888 + { + log::warn!( + "DRM scanout fourcc {:#010x} is not BGRA-compatible; falling back", + frame.format + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "unsupported DRM scanout format", + )); + } + let (w, h) = (w as usize, h as usize); + let frame_size = match w.checked_mul(4).and_then(|x| x.checked_mul(h)) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + log::warn!( + "DRM scanout geometry {w}x{h} yields an out-of-range frame ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout frame too large", + )); + } + }; + // Bound the SOURCE extent too: the row loop reads up to (h-1)*stride + w*4, and `y * stride` can overflow. + match stride.checked_mul(h) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => {} + other => { + log::warn!( + "DRM scanout stride {stride} x {h} rows is out of range ({other:?} bytes); falling back" + ); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + "DRM scanout stride out of range", + )); + } + } + if self.buf.len() != frame_size { + self.buf.resize(frame_size, 0); + } + let src = frame.data as *const u8; + let dst = self.buf.as_mut_ptr(); + if stride == w * 4 { + std::ptr::copy_nonoverlapping(src, dst, frame_size); + } else { + for y in 0..h { + std::ptr::copy_nonoverlapping(src.add(y * stride), dst.add(y * w * 4), w * 4); + } + } + (self.lib.frame_release)(self.ctx, &mut frame); + Ok((&self.buf, w, h)) + } + } + + /// Render node of the GPU this reader captures from, so the converter binds to the device that EXPORTS the scanout: + /// importing across vendors can fail on an incompatible tiling modifier. `None` if the symbol is absent or the device is display-only. + pub fn render_node(&mut self) -> Option { + let f = self.lib.render_node?; + // SAFETY: self.ctx is valid; the returned pointer is owned by the context and stays valid until it is closed. + let ptr = unsafe { f(self.ctx) }; + if ptr.is_null() { + return None; + } + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .ok() + .map(|s| s.to_owned()) + } + + /// Zero-copy EXPORT grab: fills a `drmtap_dmabuf_desc` (dma-buf fd, plane layout, HDR metadata) WITHOUT mapping, detiling or copying pixels, so on this + /// path the root process never loads libEGL/libGLESv2. The exported fd is READ-ONLY (libdrmtap drops `DRM_RDWR` and `dup` shares that open file + /// description), so the `--server` that receives it can map the scanout but never write the live framebuffer. Validation here is METADATA ONLY. + pub fn grab_desc(&mut self) -> io::Result<(OwnedFd, drmtap_dmabuf_desc)> { + let grab_desc = self.lib.grab_desc; + // SAFETY: self.ctx is valid; desc/frame are zeroed before the call. Only paths that reach a populated frame release it: on `-EINVAL` + // libdrmtap returns before allocating, a failed inner grab has already cleaned up, and on `-ENOTSUP` libdrmtap releases the frame itself. + unsafe { + let mut desc: drmtap_dmabuf_desc = std::mem::zeroed(); + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = grab_desc(self.ctx, &mut desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + if errno == hbb_common::libc::ENOTSUP { + // A distinct error so the caller degrades to the mapped/PipeWire path instead of tight-looping a rebuild. + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "drmtap_grab_desc: no transferable dma-buf (ENOTSUP)", + )); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_grab_desc failed: errno {errno}"), + )); + } + // `desc.dma_buf_fd` is the canonical fd (what split_capture.c sends); `frame` owns it too and `frame_release` closes the library's copy. + let raw_fd = if desc.dma_buf_fd >= 0 { + desc.dma_buf_fd + } else { + frame.dma_buf_fd + }; + if raw_fd < 0 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::ErrorKind::WouldBlock.into()); + } + let w = desc.width; + let h = desc.height; + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("DRM scanout geometry {w}x{h} out of range"), + )); + } + // No fourcc gate here: the converter handles every format libdrmtap supports, and gating here dropped convertible scanouts such as XR30. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!("DRM scanout num_planes {} out of range (1..=4)", desc.num_planes), + )); + } + for p in 0..(planes as usize) { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "DRM scanout plane {p} out of range (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + // dup BEFORE releasing the frame: after release the library may recycle its handle, while an independent fd on the same open dma-buf + // keeps the buffer alive for the peer. F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec and this root service forks elsewhere. + let dup_fd = hbb_common::libc::fcntl(raw_fd, hbb_common::libc::F_DUPFD_CLOEXEC, 0); + if dup_fd < 0 { + let e = io::Error::last_os_error(); + (self.lib.frame_release)(self.ctx, &mut frame); + return Err(e); + } + let owned = OwnedFd::from_raw_fd(dup_fd); + (self.lib.frame_release)(self.ctx, &mut frame); + desc.num_planes = planes; + desc.dma_buf_fd = -1; + Ok((owned, desc)) + } + } + + /// Read the hardware cursor plane: the hidden sentinel when the plane reports the cursor invisible, the real shape when visible, and `None` when the read fails. + pub fn cursor(&mut self) -> Option { + // SAFETY: ctx valid; c zeroed; released on EVERY path after a successful get_cursor. Only a failed get_cursor returns without releasing, because then there is nothing to release. + unsafe { + let mut c: drmtap_cursor_info = std::mem::zeroed(); + let cret = (self.lib.get_cursor)(self.ctx, &mut c); + if cret != 0 { + return None; + } + let out = if c.visible == 0 { + Some(CursorSnapshot { + id: HIDDEN_CURSOR_ID, + width: 1, + height: 1, + hotx: 0, + hoty: 0, + colors: vec![0, 0, 0, 0], + }) + } else if !c.pixels.is_null() + && c.width > 0 + && c.height > 0 + && (c.width as i64) * (c.height as i64) <= 256 * 256 + { + let cw = c.width as i32; + let ch = c.height as i32; + let n = (cw * ch) as usize; + let src = std::slice::from_raw_parts(c.pixels, n); + let mut hash: u64 = 1469598103934665603; + let mut colors = Vec::with_capacity(n * 4); + let (mut minx, mut miny, mut maxx, mut maxy) = (cw, ch, -1i32, -1i32); + for (i, &p) in src.iter().enumerate() { + let a = ((p >> 24) & 0xff) as u8; + let r = ((p >> 16) & 0xff) as u8; + let g = ((p >> 8) & 0xff) as u8; + let b = (p & 0xff) as u8; + colors.push(r); + colors.push(g); + colors.push(b); + colors.push(a); + hash ^= p as u64; + hash = hash.wrapping_mul(1099511628211); + if a >= 128 { + let x = (i as i32) % cw; + let y = (i as i32) / cw; + if x < minx { minx = x; } + if x > maxx { maxx = x; } + if y < miny { miny = y; } + if y > maxy { maxy = y; } + } + } + let (hotx, hoty) = if c.hot_x != 0 || c.hot_y != 0 { + (c.hot_x, c.hot_y) + } else if maxx >= minx && maxy >= miny { + let (bw, bh) = (maxx - minx + 1, maxy - miny + 1); + if bh > bw * 2 { + ((minx + maxx) / 2, (miny + maxy) / 2) + } else { + (minx, miny) + } + } else { + (0, 0) + }; + // Fold geometry + hotspot into the id: identical pixels with a changed size or + // hotspot must count as a new shape, otherwise drm_capture_worker suppresses the + // update (it dedupes by id) and the client keeps rendering the stale cursor. + let mut id = hash; + for v in [cw as u32 as u64, ch as u32 as u64, hotx as u32 as u64, hoty as u32 as u64] { + id ^= v; + id = id.wrapping_mul(1099511628211); + } + Some(CursorSnapshot { + id, + width: cw as u32, + height: ch as u32, + hotx, + hoty, + colors, + }) + } else { + None + }; + (self.lib.cursor_release)(self.ctx, &mut c); + out + } + } + + pub fn displays(&mut self) -> Vec { + // SAFETY: ctx valid; raw is a zeroed, correctly-sized array; count is clamped to the buffer before indexing. + unsafe { + let mut raw = vec![std::mem::zeroed::(); 16]; + let cap = raw.len() as i32; + let n = (self.lib.list_displays)(self.ctx, raw.as_mut_ptr(), cap); + if n <= 0 { + return Vec::new(); + } + let count = (n as usize).min(raw.len()); + (0..count) + .map(|i| { + let name_bytes: Vec = raw[i] + .name + .iter() + .take_while(|&&ch| ch != 0) + .map(|&ch| ch as u8) + .collect(); + DisplaySnapshot { + name: String::from_utf8_lossy(&name_bytes).to_string(), + crtc_id: raw[i].crtc_id, + x: raw[i].x as i32, + y: raw[i].y as i32, + width: raw[i].width, + height: raw[i].height, + active: raw[i].active != 0, + } + }) + .collect() + } + } +} + +impl Drop for DrmReader { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open and is non-null. + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drm_render.rs b/libs/scrap/src/common/drm_render.rs new file mode 100644 index 000000000..f12df71f7 --- /dev/null +++ b/libs/scrap/src/common/drm_render.rs @@ -0,0 +1,184 @@ +// Unprivileged half of the split DRM/KMS capture path: the root `--service` exports a scanout +// dma-buf fd + descriptor, this side imports it and EGL-detiles. libEGL/libGLESv2 are dlopen'd +// in the UNPRIVILEGED process on this path; the root service loads them only if it falls back to +// its own CPU-mapped grab (`drmtap_grab_mapped`). See docs/DRM_CAPTURE_SECURITY.md. + +use super::drmtap_dl::{self, drmtap_ctx, drmtap_dmabuf_desc, drmtap_frame_info, DrmtapLib}; +use super::Pixfmt; +use hbb_common::log; +use std::ffi::CString; +use std::io; +use std::os::fd::RawFd; + +use super::drm_reader::{ + DRM_FORMAT_ABGR8888, DRM_FORMAT_ARGB8888, DRM_FORMAT_XBGR8888, DRM_FORMAT_XRGB8888, + MAX_DIM, MAX_FRAME_BYTES, +}; + +/// Unprivileged DRM render-node convert context. !Send/!Sync via the raw ctx pointer: the context +/// and libdrmtap's thread-local EGL state must be created, used (`convert`) and closed on ONE thread. +pub struct RenderConverter { + lib: &'static DrmtapLib, + ctx: *mut drmtap_ctx, +} + +impl RenderConverter { + /// `node` is the render node of the GPU that exports the scanout; `None`/invalid path falls back to libdrmtap auto-selection. + pub fn open_render(node: Option<&str>) -> Option { + let lib = drmtap_dl::get()?; + let open_render = lib.open_render; + let node_cstr = match node.filter(|n| !n.is_empty()) { + None => None, + // Open the CANONICAL path the gate resolved: opening the IPC string would re-walk its symlinks after the check. + Some(n) => match super::drm_reader::device_under_dev_dri(n) { + None => { + log::warn!("drm: render node {n:?} is not under /dev/dri; auto-selecting"); + None + } + Some(canonical) => canonical.to_str().and_then(|s| CString::new(s).ok()), + }, + }; + // SAFETY: resolved C entry point; `node_cstr` outlives the call, NULL requests auto-selection. + let ctx = unsafe { + open_render(node_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr())) + }; + if ctx.is_null() { + log::info!( + "drmtap_open_render({}) failed; no usable DRM render node", + node_cstr.as_ref().map_or("NULL".to_owned(), |c| format!("{c:?}")) + ); + return None; + } + match node_cstr { + Some(c) => log::info!( + "drm: opened unprivileged convert context on the exporting GPU ({c:?})" + ), + None => log::info!( + "drm: opened unprivileged render-node convert context (auto-selected)" + ), + } + Some(RenderConverter { lib, ctx }) + } + + /// Returns context-owned linear pixels valid ONLY until the next `convert()`; row stride is `len / height`. + pub fn convert( + &mut self, + desc: &mut drmtap_dmabuf_desc, + received_fd: RawFd, + ) -> io::Result<(&[u8], u32, u32, Pixfmt)> { + { + let (w, h) = (desc.width, desc.height); + if w == 0 || h == 0 || w > MAX_DIM || h > MAX_DIM { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("drm: refusing a dma-buf descriptor with geometry {w}x{h}"), + )); + } + // Reject, do not clamp, and write the normalized count back so the C reads the count bounded here. + let planes = if desc.num_planes == 0 { 1 } else { desc.num_planes }; + if planes > 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing a dma-buf descriptor with num_planes {} (1..=4)", + desc.num_planes + ), + )); + } + desc.num_planes = planes; + let planes = planes as usize; + for p in 0..planes { + let extent = (desc.pitches[p] as usize) + .checked_mul(h as usize) + .and_then(|rows| rows.checked_add(desc.offsets[p] as usize)); + match extent { + Some(end) if end <= MAX_FRAME_BYTES => {} + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "drm: refusing dma-buf plane {p} (offset {} pitch {} over {h} rows -> {other:?}, cap {MAX_FRAME_BYTES})", + desc.offsets[p], desc.pitches[p] + ), + )); + } + } + } + } + let convert_dmabuf = self.lib.convert_dmabuf; + // LOAD-BEARING: the fd the exporter serialized was process-local; -1 means reuse the cached import for `fb_id`. + desc.dma_buf_fd = received_fd; + // SAFETY: self.ctx is a valid render context; `desc` is fully initialized; `frame` is zeroed + // before the call. libdrmtap OWNS `frame.data`: no release/free from this side (drmtap.h). + unsafe { + let mut frame: drmtap_frame_info = std::mem::zeroed(); + let ret = convert_dmabuf(self.ctx, &*desc as *const drmtap_dmabuf_desc, &mut frame); + if ret < 0 { + let errno = -ret; + if errno == hbb_common::libc::EAGAIN + || errno == hbb_common::libc::EBUSY + || errno == hbb_common::libc::EINTR + { + return Err(io::ErrorKind::WouldBlock.into()); + } + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf failed: errno {errno}"), + )); + } + if frame.data.is_null() || frame.width == 0 || frame.height == 0 || frame.stride == 0 { + return Err(io::Error::new( + io::ErrorKind::Other, + "drmtap_convert_dmabuf produced an empty frame", + )); + } + let w = frame.width; + let h = frame.height; + let stride = frame.stride as usize; + // A stride below 32bpp under-sizes the row and, read as BGRA downstream, discloses adjacent memory. + if w > MAX_DIM || h > MAX_DIM || stride < (w as usize) * 4 { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drmtap_convert_dmabuf bad geometry {w}x{h} stride {stride} fourcc {:#010x}", + frame.format + ), + )); + } + let len = match stride.checked_mul(h as usize) { + Some(sz) if sz > 0 && sz <= MAX_FRAME_BYTES => sz, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf frame size out of range ({other:?} bytes)"), + )); + } + }; + let pixfmt = match frame.format { + DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => Pixfmt::BGRA, + DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => Pixfmt::RGBA, + // Unset by an older convert -> libdrmtap's normalized BGRA. + 0 => Pixfmt::BGRA, + other => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("drmtap_convert_dmabuf produced an unsupported output fourcc {other:#010x}"), + )); + } + }; + let data = std::slice::from_raw_parts(frame.data as *const u8, len); + Ok((data, w, h, pixfmt)) + } + } +} + +impl Drop for RenderConverter { + fn drop(&mut self) { + if !self.ctx.is_null() { + // SAFETY: ctx came from drmtap_open_render and is non-null; the !Send ctx pointer keeps + // this drop on the thread that created and used it (thread-local EGL + cached imports). + unsafe { (self.lib.close)(self.ctx) }; + self.ctx = std::ptr::null_mut(); + } + } +} diff --git a/libs/scrap/src/common/drmtap_dl.rs b/libs/scrap/src/common/drmtap_dl.rs new file mode 100644 index 000000000..63b46ce8b --- /dev/null +++ b/libs/scrap/src/common/drmtap_dl.rs @@ -0,0 +1,410 @@ +// Runtime loader for libdrmtap.so (the DRM/KMS capture engine), dlopen'd so the binary carries no hard libdrm/libEGL/libGLESv2 dependency. + +use hbb_common::{libloading::Library, log}; +use std::os::raw::{c_char, c_int, c_void}; +use std::sync::OnceLock; + +// C ABI structs: must match libdrmtap include/drmtap.h. + +#[repr(C)] +pub struct drmtap_ctx { + _private: [u8; 0], +} + +#[repr(C)] +pub struct drmtap_config { + pub device_path: *const c_char, // NULL = auto-detect /dev/dri/card* + pub crtc_id: u32, // 0 = auto-select first active CRTC + pub helper_path: *const c_char, // only consulted if the direct DRM export is denied (no CAP_SYS_ADMIN) + pub debug: c_int, +} + +impl Default for drmtap_config { + fn default() -> Self { + Self { + device_path: std::ptr::null(), + crtc_id: 0, + helper_path: std::ptr::null(), + debug: 0, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_display { + pub crtc_id: u32, + pub connector_id: u32, + pub name: [c_char; 32], + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub refresh_hz: u32, + pub active: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_device { + pub path: [c_char; 64], + pub render_node: [c_char; 64], + pub driver: [c_char; 32], + pub display_count: u32, +} + +#[repr(C)] +pub struct drmtap_frame_info { + pub data: *mut c_void, + pub dma_buf_fd: c_int, + pub width: u32, + pub height: u32, + pub stride: u32, + pub format: u32, + pub modifier: u64, + pub fb_id: u32, + pub _priv: *mut c_void, +} + +// Descriptor of an externally-supplied scanout DMA-BUF: the privileged exporter fills it via +// `drmtap_grab_desc`; the converter overwrites `dma_buf_fd` with the fd it got via SCM_RIGHTS. +// Mirrors `drmtap_dmabuf_desc` EXACTLY (field order + widths); a mismatch mis-reads CCS/HDR scanouts. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct drmtap_dmabuf_desc { + pub dma_buf_fd: c_int, // scanout DMA-BUF; -1 for an already-imported fb_id + pub width: u32, + pub height: u32, + pub format: u32, // DRM fourcc of the scanout + pub modifier: u64, // DRM format modifier (tiling/compression) + pub fb_id: u32, // import-once cache key; 0 disables caching + pub num_planes: u32, // used entries in offsets/pitches (1..4); 0 => 1 + pub offsets: [u32; 4], // per-plane byte offsets (CCS main+aux+clear-color) + pub pitches: [u32; 4], // per-plane strides; pitches[0] = main stride + pub hdr_eotf: u32, // DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3) + pub hdr_max_nits: u32, // mastering/content peak luminance cd/m2; 0=unknown +} + +impl Default for drmtap_dmabuf_desc { + fn default() -> Self { + Self { + dma_buf_fd: -1, + width: 0, + height: 0, + format: 0, + modifier: 0, + fb_id: 0, + num_planes: 0, + offsets: [0; 4], + pitches: [0; 4], + hdr_eotf: 0, + hdr_max_nits: 0, + } + } +} + +#[repr(C)] +pub struct drmtap_cursor_info { + pub x: i32, + pub y: i32, + pub hot_x: i32, + pub hot_y: i32, + pub width: u32, + pub height: u32, + pub pixels: *mut u32, + pub visible: c_int, + pub _priv: *mut c_void, +} + +// Resolved symbol typedefs. + +type FnVersion = unsafe extern "C" fn() -> c_int; +type FnOpen = unsafe extern "C" fn(*const drmtap_config) -> *mut drmtap_ctx; +type FnClose = unsafe extern "C" fn(*mut drmtap_ctx); +type FnListDisplays = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_display, c_int) -> c_int; +type FnListDevices = unsafe extern "C" fn(*mut drmtap_device, c_int) -> c_int; +type FnGrabMapped = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info) -> c_int; +type FnFrameRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_frame_info); +type FnGetCursor = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info) -> c_int; +type FnCursorRelease = unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_cursor_info); +// Split-capture entry points (libdrmtap >= 0.4.10), required: `grab_desc` runs on the privileged +// export side, `open_render`/`convert_dmabuf` on the unprivileged converter side. +type FnGrabDesc = + unsafe extern "C" fn(*mut drmtap_ctx, *mut drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; +type FnOpenRender = unsafe extern "C" fn(*const c_char) -> *mut drmtap_ctx; +// libdrmtap >= 0.4.15; returns a ctx-owned string, or NULL if it has none. +type FnRenderNode = unsafe extern "C" fn(*mut drmtap_ctx) -> *const c_char; +type FnConvertDmabuf = + unsafe extern "C" fn(*mut drmtap_ctx, *const drmtap_dmabuf_desc, *mut drmtap_frame_info) -> c_int; + +/// The dlopen'd libdrmtap; the `Library` is kept alive for the process lifetime, so the raw fn pointers stay valid. +pub struct DrmtapLib { + _lib: Library, + pub open: FnOpen, + pub close: FnClose, + pub list_displays: FnListDisplays, + pub list_devices: Option, + pub grab_mapped: FnGrabMapped, + pub frame_release: FnFrameRelease, + pub get_cursor: FnGetCursor, + pub cursor_release: FnCursorRelease, + pub grab_desc: FnGrabDesc, + pub open_render: FnOpenRender, + pub convert_dmabuf: FnConvertDmabuf, + pub render_node: Option, + pub version: (c_int, c_int, c_int), +} + +// SAFETY: the resolved fn pointers are plain C entry points with no interior mutability; +// libdrmtap contexts are used single-threaded by the caller. The Library handle is never moved out. +unsafe impl Send for DrmtapLib {} +unsafe impl Sync for DrmtapLib {} + +const DRMTAP_ABI_MAJOR: c_int = 0; + +// Lowest (minor, patch) accepted. 0.5.0 is the floor because it fixes the padded-framebuffer read +// (a scanout whose pitch exceeds width*bpp was decoded at the wrong stride); the whole split API +// has been present since 0.4.10. +const DRMTAP_MIN_MINOR_PATCH: (c_int, c_int) = (5, 0); + +// The MINOR series this build's mirrored structs were verified against: libdrmtap's header freezes +// only `drmtap_device` and `drmtap_dmabuf_desc`, so an unverified minor could be read at wrong offsets. +const DRMTAP_ABI_MINOR: c_int = 5; + +/// Whether a library reporting `major.minor.patch` may be loaded (major and minor exact, patch at or above the floor). +fn abi_accepted(major: c_int, minor: c_int, patch: c_int) -> bool { + major == DRMTAP_ABI_MAJOR + && minor == DRMTAP_ABI_MINOR + && (minor, patch) >= DRMTAP_MIN_MINOR_PATCH +} + +impl DrmtapLib { + fn load() -> Option { + // Absolute path FIRST: the deb bundles the .so privately under /usr/lib/rustdesk and does NOT register that dir with ld.so. + const INSTALLED: &str = "/usr/lib/rustdesk/libdrmtap.so.0"; + // Bare sonames exist so an unpackaged development build can load a locally built .so from + // the normal ld.so search path. They are NOT offered when running as root: this is the one + // place where which file happens to be on the load path decides what gets mapped into the + // CAP_SYS_ADMIN process, and the packaged service always finds the absolute path first + // anyway. A root process that reaches the fallback has no bundled library, which is the + // PipeWire-fallback case, not a reason to search. + const DEV_ONLY: [&str; 2] = ["libdrmtap.so.0", "libdrmtap.so"]; + let is_root = unsafe { hbb_common::libc::geteuid() } == 0; + let candidates: Vec<&str> = if is_root { + vec![INSTALLED] + } else { + std::iter::once(INSTALLED).chain(DEV_ONLY).collect() + }; + unsafe { + let (lib, name) = candidates + .iter() + .find_map(|n| Library::new(*n).ok().map(|l| (l, *n)))?; + // Canonicalize the absolute candidate only: `dlopen` does not search the CWD for a bare + // soname, while `canonicalize` resolves a relative name against it. + let real = std::path::Path::new(name) + .is_absolute() + .then(|| std::fs::canonicalize(name).ok()) + .flatten(); + let version: FnVersion = *lib.get(b"drmtap_version").ok()?; + let v = version(); + let (major, minor, patch) = ((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); + if !abi_accepted(major, minor, patch) { + let why = if major != DRMTAP_ABI_MAJOR { + "the struct layouts this build mirrors track the ABI major, so reading a \ + frame descriptor through a mismatched one would mis-decode it" + } else if minor != DRMTAP_ABI_MINOR { + "this build mirrors the struct layouts of one minor and only that one; \ + under 0.x semver the minor is the breaking axis, so an unverified minor \ + could be read at the wrong offsets. Widening it is a deliberate act, done \ + with the layouts re-checked field by field" + } else { + "it predates the split-capture API, so its only capture path converts \ + in-process, which in the root service means loading the GL stack there" + }; + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch}, which this build cannot \ + use (needs ABI major {DRMTAP_ABI_MAJOR}, minor {DRMTAP_ABI_MINOR}, at least \ + v{DRMTAP_ABI_MAJOR}.{min_minor}.{min_patch}): {why}. Refusing to load; \ + falling back to PipeWire/portal." + ); + return None; + } + let open: FnOpen = *lib.get(b"drmtap_open").ok()?; + let close: FnClose = *lib.get(b"drmtap_close").ok()?; + let list_displays: FnListDisplays = *lib.get(b"drmtap_list_displays").ok()?; + let list_devices: Option = + lib.get(b"drmtap_list_devices").ok().map(|s| *s); + let grab_mapped: FnGrabMapped = *lib.get(b"drmtap_grab_mapped").ok()?; + let frame_release: FnFrameRelease = *lib.get(b"drmtap_frame_release").ok()?; + let get_cursor: FnGetCursor = *lib.get(b"drmtap_get_cursor").ok()?; + let cursor_release: FnCursorRelease = *lib.get(b"drmtap_cursor_release").ok()?; + let grab: Option = lib.get(b"drmtap_grab_desc").ok().map(|s| *s); + let open_r: Option = lib.get(b"drmtap_open_render").ok().map(|s| *s); + let conv: Option = + lib.get(b"drmtap_convert_dmabuf").ok().map(|s| *s); + let (grab_desc, open_render, convert_dmabuf) = match (grab, open_r, conv) { + (Some(g), Some(o), Some(c)) => (g, o, c), + (grab, open_r, conv) => { + let mut missing = Vec::new(); + if grab.is_none() { + missing.push("drmtap_grab_desc"); + } + if open_r.is_none() { + missing.push("drmtap_open_render"); + } + if conv.is_none() { + missing.push("drmtap_convert_dmabuf"); + } + log::warn!( + "libdrmtap {name} reports v{major}.{minor}.{patch} but does not export \ + {}: it is a stale or pre-release build, not the version it claims. \ + Refusing to load; falling back to PipeWire/portal.", + missing.join(", ") + ); + return None; + } + }; + let render_node: Option = + lib.get(b"drmtap_render_node").ok().map(|s| *s); + // Log the load only now that every required symbol resolved: this fn still returns None on a missing one. + let loaded_from = real + .as_ref() + .map_or_else(|| name.to_owned(), |p| p.display().to_string()); + if loaded_from == name { + log::info!("libdrmtap loaded: {name} (v{major}.{minor}.{patch})"); + } else { + log::info!("libdrmtap loaded: {name} -> {loaded_from} (v{major}.{minor}.{patch})"); + } + let (no_node, no_devices) = (render_node.is_none(), list_devices.is_none()); + if (minor, patch) >= (4, 15) && (no_node || no_devices) { + let missing = if no_node && no_devices { + "drmtap_render_node and drmtap_list_devices" + } else if no_node { + "drmtap_render_node" + } else { + "drmtap_list_devices" + }; + let effect = if no_node && no_devices { + "Multi-GPU display enumeration and exporting-GPU selection stay disabled." + } else if no_node { + "Exporting-GPU selection stays disabled." + } else { + "Multi-GPU display enumeration stays disabled." + }; + log::warn!( + "libdrmtap at {loaded_from} reports v{major}.{minor}.{patch} but is missing \ + {missing}: it is a stale or pre-release build. Check what the soname symlink \ + points at and remove any leftover libdrmtap.so.0* beside it. {effect}" + ); + } + Some(DrmtapLib { + _lib: lib, + open, + close, + list_displays, + list_devices, + grab_mapped, + frame_release, + get_cursor, + cursor_release, + grab_desc, + open_render, + convert_dmabuf, + render_node, + version: (major, minor, patch), + }) + } + } +} + +static DRMTAP_LIB: OnceLock> = OnceLock::new(); + +/// The loaded libdrmtap, or None if the .so (or a runtime dep) is absent or its version/exports fall outside the ABI gate. Loaded once; a failure is remembered. +pub fn get() -> Option<&'static DrmtapLib> { + DRMTAP_LIB + .get_or_init(|| { + let lib = DrmtapLib::load(); + if lib.is_none() { + log::info!("libdrmtap not available or not usable; DRM capture disabled"); + } + lib + }) + .as_ref() +} + +#[cfg(test)] +mod tests { + use super::{abi_accepted, DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, DRMTAP_MIN_MINOR_PATCH}; + + #[test] + fn abi_gate_rejects_a_library_from_before_the_split() { + // These are refused because their MINOR differs from the verified one, which is the only + // reason the gate needs. Naming the pre-split releases keeps the intent readable, but do + // not read this as the floor doing the work: see the test below. + for (minor, patch) in [(3, 3), (4, 0), (4, 8), (4, 9)] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is not the verified minor and must be refused" + ); + } + } + + #[test] + fn the_patch_floor_is_currently_vacuous_and_that_is_deliberate() { + // With MIN_MINOR_PATCH.0 == DRMTAP_ABI_MINOR the floor can never reject anything: the + // minor equality already forces `(minor, patch) >= (minor, 0)`. It is kept because it is + // the mechanism that WOULD do the work the next time a floor lands mid-minor, as (4, 10) + // did for the split API. This test exists so nobody reads the pre-split test above as + // evidence that the floor is live -- if that ever matters, this assert is the tripwire. + let (floor_minor, floor_patch) = DRMTAP_MIN_MINOR_PATCH; + assert_eq!( + floor_minor, DRMTAP_ABI_MINOR, + "the floor is inside the verified minor; a floor in a DIFFERENT minor is unreachable" + ); + if floor_patch == 0 { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, 0), + "patch 0 of the verified minor must be accepted while the floor is 0" + ); + } else { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR, DRMTAP_ABI_MINOR, floor_patch - 1)); + } + } + + #[test] + fn abi_gate_accepts_the_floor_and_later_patches_of_the_same_minor() { + let (min_minor, min_patch) = DRMTAP_MIN_MINOR_PATCH; + assert!(abi_accepted(DRMTAP_ABI_MAJOR, min_minor, min_patch)); + for (minor, patch) in [(DRMTAP_ABI_MINOR, min_patch + 15), (DRMTAP_ABI_MINOR, 200)] { + assert!( + abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is a patch of the verified minor and must be accepted" + ); + } + } + + #[test] + fn abi_gate_rejects_an_unknown_newer_minor() { + // Relative to DRMTAP_ABI_MINOR, so the next bump cannot leave this test asserting that the + // NEW verified minor must be refused -- which is what a hardcoded list did before. + let verified = DRMTAP_ABI_MINOR; + for (minor, patch) in [ + (verified - 1, 99), + (verified + 1, 0), + (verified + 1, 99), + (verified + 4, 9), + ] { + assert!( + !abi_accepted(DRMTAP_ABI_MAJOR, minor, patch), + "v0.{minor}.{patch} is an unverified minor and must be refused" + ); + } + } + + #[test] + fn abi_gate_rejects_another_major_in_both_directions() { + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 0, 0)); + assert!(!abi_accepted(DRMTAP_ABI_MAJOR + 1, 99, 99)); + } +} diff --git a/libs/scrap/src/common/mod.rs b/libs/scrap/src/common/mod.rs index 2d74caa0d..1efed1176 100644 --- a/libs/scrap/src/common/mod.rs +++ b/libs/scrap/src/common/mod.rs @@ -16,6 +16,12 @@ cfg_if! { mod linux; mod wayland; mod x11; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drmtap_dl; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_reader; + #[cfg(all(target_os = "linux", feature = "drm"))] + pub mod drm_render; pub use self::linux::*; pub use self::wayland::set_map_err; pub use self::x11::PixelBuffer; diff --git a/src/ipc.rs b/src/ipc.rs index 188c2e467..b3abeeb55 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -3,6 +3,21 @@ mod ipc_auth; #[cfg(any(target_os = "linux", target_os = "macos"))] #[path = "ipc/fs.rs"] mod ipc_fs; +// The DRM/KMS capture producer, the `_drm` channel and its SCM_RIGHTS framing live in their own +// module, declared the same way as the other pieces of this file, so the opt-in feature adds a +// bounded, self-contained surface here instead of ~1800 lines in the middle of the shared IPC. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[path = "ipc/drm.rs"] +mod ipc_drm; +// Re-exported so the paths callers already use (`crate::ipc::start_drm`, `crate::ipc::connect_drm`, +// `crate::ipc::DrmDisplayInfo`) keep working, and so the `Data` variants can name the two +// payload types. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub use ipc_drm::{start_drm, DmabufDesc, DrmDisplayInfo}; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::DrmConn; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) use ipc_drm::connect_drm; #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -60,6 +75,9 @@ use ipc_fs::{ check_pid, ensure_secure_ipc_parent_dir, scrub_secure_ipc_parent_dir, should_scrub_parent_entries_after_check_pid, write_pid, }; +// Gated with the module that uses it, so a `drm`-less build does not carry an unused import. +#[cfg(all(target_os = "linux", feature = "drm"))] +use ipc_fs::remove_ipc_entry_via_secure_parent_fd; use parity_tokio_ipc::{ Connection as Conn, ConnectionClient as ConnClient, Endpoint, Incoming, SecurityAttributes, }; @@ -481,6 +499,51 @@ pub enum Data { ControlPermissionsRemoteModify(Option), #[cfg(target_os = "windows")] FileTransferEnabledState(Option), + // --- DRM/KMS capture (opt-in `drm` feature) over the `_drm` service-scoped channel --- + // All of the following are `cfg(all(linux, drm))`, so the drm-off IPC wire is byte-identical + // to upstream. Protocol on `_drm`: on connect the root service sends `DrmDisplayList`, the + // client replies `DrmStart{display}`, then the service streams `DrmFrame` + send_raw(BGRA) and + // `DrmCursor` + send_raw(RGBA). A frame/cursor header is ALWAYS immediately followed by exactly + // one `send_raw()` payload (the same header-then-raw pairing as `FileBlockFromCM`). This keeps + // the header extensible. The zero-copy `DrmFrameDmabuf(DmabufDesc)` sibling below carries only a + // small JSON metadata descriptor; the scanout dma-buf fd rides an SCM_RIGHTS ancillary message on + // the same `DrmConn` send (see `DrmConn::send_msg`), so it has NO trailing `send_raw()` body. + /// Client -> service: begin streaming the chosen display. + #[cfg(all(target_os = "linux", feature = "drm"))] + // `need_cpu` is set by an unprivileged consumer that could not open a render-node convert context + // (drmtap_open_render failed, e.g. no /dev/dri/renderD* access). The service then streams the + // CPU-converted `DrmFrame` path for this connection instead of a dma-buf fd the consumer cannot + // detile, so a render-node-less seat still captures instead of losing the stream. + DrmStart { display: i32, need_cpu: bool }, + /// Service -> client: the enumerated DRM displays (sent once, before frames). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplayList(Vec), + /// Service -> client: the connector topology changed mid-stream (a monitor hotplug/unplug/modeset, + /// observed by the service's udev DRM-uevent listener). Carries the freshly-enumerated list so the + /// consumer can swap its sticky positive availability cache off the hot path, WITHOUT re-probing + /// `_drm` (which would trip the enumeration restart loop). Interleaved with frames on the same + /// stream; carries no `send_raw()` body and no fd. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmDisplaysChanged(Vec), + /// Service -> client: a frame header; the packed BGRA pixels follow via `send_raw()`. + /// CPU-fallback path (no render node, or no transferable dma-buf): pixels cross the wire. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrame { width: u32, height: u32 }, + /// Service -> client: a zero-copy dma-buf frame descriptor. The scanout fd is NOT a field; when + /// `desc.has_fd` it rides an SCM_RIGHTS ancillary message on the same `DrmConn::send_msg`, and + /// there is NO trailing `send_raw()` body. The unprivileged `--server` imports the fd and does + /// the EGL detile/convert itself (see `DmabufDesc`). + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmFrameDmabuf(DmabufDesc), + /// Service -> client: a hardware-cursor header; the RGBA pixels follow via `send_raw()`. + #[cfg(all(target_os = "linux", feature = "drm"))] + DrmCursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + }, } #[tokio::main(flavor = "current_thread")] diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 0dd43855e..89beef072 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -208,6 +208,17 @@ pub(crate) fn active_uid() -> Option { active_uid_strict() } +/// The active session uid read ONLY from the service-loop cache, never from a fresh (blocking) seat0 +/// lookup. `None` on a cache miss. For hot, latency-sensitive, fail-closed re-auth on an async runtime +/// thread (the `_drm` per-frame re-auth), where a blocking `loginctl` per frame would stall the stream. +// Gated with the feature, not just the OS: the `_drm` per-frame re-auth is its only caller, so a +// drm-off Linux build would carry it as dead code and warn about it. +#[cfg(all(target_os = "linux", feature = "drm"))] +#[inline] +pub(crate) fn active_uid_cached() -> Option { + crate::platform::linux::get_active_userid_cached() +} + #[cfg(any(target_os = "linux", target_os = "macos"))] #[inline] pub(crate) fn peer_uid_from_fd(fd: RawFd) -> Option { diff --git a/src/ipc/drm.rs b/src/ipc/drm.rs new file mode 100644 index 000000000..c2c399e6f --- /dev/null +++ b/src/ipc/drm.rs @@ -0,0 +1,1799 @@ +// The DRM/KMS capture half of the `_drm` IPC channel: types, root-service producer, framing. + +use super::ipc_auth::active_uid_cached; +use super::*; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DrmDisplayInfo { + pub name: String, + pub crtc_id: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub active: bool, + /// Render node of the GPU that EXPORTS this display's scanout; on a multi-GPU host auto-select + /// can bind a different GPU whose cross-vendor import then fails. Empty when the service cannot + /// name it: the consumer then auto-selects on a single-render-node host, and forces the CPU + /// path where there are several. + #[serde(default)] + pub render_node: String, + /// KMS card node (`/dev/dri/card*`) driving this display. crtc_ids are card-local, so the index + /// alone is ambiguous across cards. Empty = the single auto-detected device. + #[serde(default)] + pub device: String, +} + +/// Mirrors `scrap::drm_reader::drmtap_dmabuf_desc` except `dma_buf_fd` (never serializes — it rides +/// SCM_RIGHTS ancillary), and adds `buffer_id` (fb_id tagged with a per-connection epoch; no consumer reads it today) and `has_fd`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DmabufDesc { + pub buffer_id: u64, + pub width: u32, + pub height: u32, + pub format: u32, + pub modifier: u64, + /// KMS framebuffer id — libdrmtap's import-once cache key. 0 disables caching for this frame. + pub fb_id: u32, + /// Used entries in `offsets`/`pitches` (1..4); 0 is treated as 1. + pub num_planes: u32, + pub offsets: [u32; 4], + pub pitches: [u32; 4], + /// DRMTAP_EOTF_* (SDR=0, PQ=2, HLG=3). PQ triggers the HDR->SDR tone-map on convert. + pub hdr_eotf: u32, + pub hdr_max_nits: u32, + /// True: the fd rides this message's SCM_RIGHTS cmsg. False: import-once cache hit for `fb_id`. + pub has_fd: bool, +} + +pub(crate) fn drm_ipc_path() -> String { + let service_path = Config::ipc_path("_service"); + let dir = std::path::Path::new(&service_path) + .parent() + .unwrap_or_else(|| std::path::Path::new("/tmp")); + dir.join("ipc_drm").to_string_lossy().into_owned() +} + +pub(crate) async fn connect_drm(ms_timeout: u64) -> ResultType { + use std::os::fd::AsRawFd; + let path = drm_ipc_path(); + let stream = timeout(ms_timeout, tokio::net::UnixStream::connect(&path)).await??; + // The producer MUST be root: a non-root peer that won a socket-path race must not be trusted to + // supply the display list, frames and an arbitrary dma-buf fd. + if peer_uid_from_fd(stream.as_raw_fd()) != Some(0) { + bail!("drm: _drm producer is not root; refusing to consume"); + } + Ok(DrmConn::new(stream)) +} + +/// Bind the `_drm` listener 0666: connectable by any local uid, authorized in `handle_drm_conn`. +fn new_drm_listener() -> ResultType { + let path = drm_ipc_path(); + let _ = ensure_secure_ipc_parent_dir(&path, "_service")?; + // NOT `std::fs::remove_file`: `unlink(2)` returns EISDIR against a directory-typed squatter and + // the bind then fails EADDRINUSE; the fd-based helper picks `AT_REMOVEDIR` (empty dirs only). + if let Err(err) = remove_ipc_entry_via_secure_parent_fd(&path) { + log::warn!("drm: could not clear a stale entry at {}: {}", &path, err); + } + let mut endpoint = Endpoint::new(path.clone()); + endpoint.set_security_attributes(SecurityAttributes::allow_everyone_create()?); + let incoming = endpoint.incoming()?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).map_err(|err| { + std::fs::remove_file(&path).ok(); + err + })?; + log::info!("Started drm ipc server at path: {}", &path); + Ok(incoming) +} + +enum DrmProducerMsg { + /// Enumerated displays, sent once before any frame. + Displays(Vec), + /// Zero-copy path: descriptor + scanout fd; the `OwnedFd` is closed once the send has dup'd it. + Frame { + desc: DmabufDesc, + fd: Option, + }, + /// CPU-mapped fallback (packed BGRA): consumer has no convert context (`need_cpu`), or ENOTSUP. + FrameCpu { + width: u32, + height: u32, + data: Bytes, + }, + Cursor { + id: u64, + width: u32, + height: u32, + hotx: i32, + hoty: i32, + colors: Vec, + }, +} + +struct DrmStopGuard(std::sync::Arc); +impl Drop for DrmStopGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +fn dup_to_drm_conn(stream: &Connection) -> ResultType { + let raw = stream.inner.get_ref().as_raw_fd(); + // F_DUPFD_CLOEXEC, not dup(): `dup` never copies close-on-exec, and this process forks (the + // `loginctl` lookup), so an already-authorized `_drm` socket would leak into children. + let dup = unsafe { hbb_common::libc::fcntl(raw, hbb_common::libc::F_DUPFD_CLOEXEC, 0) }; + if dup < 0 { + return Err(std::io::Error::last_os_error().into()); + } + // SAFETY: `dup` is a freshly dup'd, owned fd for a connected SOCK_STREAM unix socket. + let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(dup) }; + std_stream.set_nonblocking(true)?; + let tokio_stream = tokio::net::UnixStream::from_std(std_stream)?; + Ok(DrmConn::new(tokio_stream)) +} + +static DRM_DISPLAY_CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Bumped only when a change altered `DRM_DISPLAY_CACHE`; Release orders it after the cache write. +static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Displays this reader serves, plus the identity (`device:connector`) of each undriven output. +fn drm_displays_from_reader( + reader: &mut scrap::drm_reader::DrmReader, + device: &str, +) -> (Vec, Vec) { + let render_node = reader.render_node().unwrap_or_default(); + let mut undriven = Vec::new(); + let displays: Vec = reader + .displays() + .into_iter() + // Only outputs bound to a CRTC: a CONNECTED-but-unbound connector enumerates with + // `crtc_id == 0`, and `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams ITS frames. + .filter(|d| { + if !d.active || d.crtc_id == 0 { + undriven.push(format!("{device}:{name}", name = d.name)); + return false; + } + true + }) + .map(|d| DrmDisplayInfo { + name: d.name, + crtc_id: d.crtc_id, + x: d.x, + y: d.y, + width: d.width, + height: d.height, + active: d.active, + render_node: render_node.clone(), + device: device.to_owned(), + }) + .collect(); + (displays, undriven) +} + +/// Active displays of every DRM device + the connected-but-undriven identities, from ONE look. +fn drm_enumerate_all_displays() -> (Vec, Vec) { + if let Some(devices) = scrap::drm_reader::list_devices() { + if devices.len() > 1 { + log::info!( + "drm: {} DRM devices: {}", + devices.len(), + devices + .iter() + .map(|d| format!( + "{} ({}, render {})", + d.path, + d.display_count, + if d.render_node.is_empty() { "none" } else { &d.render_node } + )) + .collect::>() + .join(", ") + ); + } + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut any_opened = false; + for dev in devices { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(&dev.path), 0) { + any_opened = true; + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, &dev.path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } else if dev.display_count == 0 { + log::debug!( + "drm: {} has no active display and did not open; cannot tell whether it has a \ + connected output that is merely switched off", + dev.path + ); + } + } + // Take this even when the list is EMPTY: the fallback re-keys identities under `device = ""`. + if any_opened { + return (all, undriven_total); + } + } + // Auto-detect alone is not enough: it picks a card that is SCANNING OUT. Measured on the T2 with + // the panel idle-disabled it binds card0 (the Touch Bar); the panel on card2 is invisible to it. + let mut all = Vec::new(); + let mut undriven_total = Vec::new(); + let mut paths: Vec = match std::fs::read_dir("/dev/dri") { + Ok(rd) => rd + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("card") && n[4..].chars().all(|c| c.is_ascii_digit())) + }) + .collect(), + Err(err) => { + log::debug!("drm: cannot read /dev/dri to enumerate cards: {err}"); + Vec::new() + } + }; + // Deterministic order, so the display list does not depend on directory order. + paths.sort(); + let n_paths = paths.len(); + for p in paths { + let Some(path) = p.to_str() else { continue }; + if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(path), 0) { + let (mut got, mut undriven) = drm_displays_from_reader(&mut r, path); + all.append(&mut got); + undriven_total.append(&mut undriven); + } + } + log::info!( + "drm: enumerated /dev/dri directly ({} card path(s)): {} active display(s), {} connected \ + but undriven", + n_paths, + all.len(), + undriven_total.len() + ); + if all.is_empty() && undriven_total.is_empty() { + if let Some(mut r) = scrap::drm_reader::DrmReader::open(None, 0) { + log::info!("drm: no card enumerated by path; falling back to the auto-detected reader"); + return drm_displays_from_reader(&mut r, ""); + } + } + (all, undriven_total) +} + +/// Connectors a wake did NOT bring back. SELF-REFUTING: an entry later seen DRIVEN is removed. +#[cfg(feature = "drm-wake")] +static DRM_WAKE_HOPELESS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[cfg(feature = "drm-wake")] +fn drm_wakeable_undriven(displays: &[DrmDisplayInfo], undriven: &[String]) -> Vec { + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !hopeless.is_empty() { + hopeless.retain(|id| { + let driven_now = displays + .iter() + .any(|d| format!("{}:{}", d.device, d.name) == *id); + if driven_now { + log::info!("drm: {id} is scanning out after all; treating it as wakeable again"); + } + !driven_now + }); + } + undriven + .iter() + .filter(|id| !hopeless.iter().any(|h| h == *id)) + .cloned() + .collect() +} + +#[cfg(feature = "drm-wake")] +static DRM_LAST_WAKE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +#[cfg(feature = "drm-wake")] +static DRM_WAKE_UNAVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Wake config key; `enable-` is load-bearing: an absent value reads as `!= "N"`, so it defaults ON. +#[cfg(feature = "drm-wake")] +const OPTION_ENABLE_DRM_DISPLAY_WAKE: &str = "enable-drm-display-wake"; + +#[cfg(feature = "drm-wake")] +const DRM_WAKE_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(20); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_DEVICE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_RECHECK_TOTAL: std::time::Duration = std::time::Duration::from_secs(3); +#[cfg(feature = "drm-wake")] +const DRM_WAKE_SETTLE_WINDOW: std::time::Duration = std::time::Duration::from_secs(5); + +/// Seconds since service start, monotonic: SystemTime would let a clock step re-open the wake gate. +#[cfg(feature = "drm-wake")] +fn drm_wake_clock_secs() -> u64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs() +} + +/// Look like user activity so the compositor re-enables an idle-DISABLED connector (until it does, +/// nothing scans out). Measured on a T2 greeter: one relative move restored a 2880x1800 scanout. +#[cfg(feature = "drm-wake")] +fn drm_wake_displays(reason: &str) -> bool { + use std::sync::atomic::Ordering; + + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return false; + } + let now = drm_wake_clock_secs(); + loop { + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last != 0 && now.saturating_sub(last) < DRM_WAKE_MIN_GAP.as_secs() { + log::debug!( + "drm: not waking displays ({reason}): a wake {}s ago is still recent", + now.saturating_sub(last) + ); + return false; + } + if DRM_LAST_WAKE + .compare_exchange(last, now.max(1), Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + break; + } + } + + // It has to look like a MOUSE: libinput ignores a device with a single relative axis and no + // buttons. Measured: REL_X + REL_Y + BTN_LEFT woke the panel; REL_X alone did not. + let mut axes = evdev::AttributeSet::::new(); + axes.insert(evdev::RelativeAxisType::REL_X); + axes.insert(evdev::RelativeAxisType::REL_Y); + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::Key::BTN_LEFT); + let built = evdev::uinput::VirtualDeviceBuilder::new() + .and_then(|b| b.name("RustDesk DRM display wake").with_relative_axes(&axes)) + .and_then(|b| b.with_keys(&keys)) + .and_then(|b| b.build()); + let mut dev = match built { + Ok(d) => d, + Err(err) => { + DRM_WAKE_UNAVAILABLE.store(true, Ordering::Relaxed); + log::warn!( + "drm: cannot wake displays ({reason}): no uinput device ({err}). A compositor that \ + disabled its outputs will keep them disabled, so there is no scanout to capture \ + until something else generates input. Note input injection needs uinput too, so \ + this session cannot control the host either." + ); + return false; + } + }; + + // A FRESH uinput device is not bound yet; events written before udev binds it are lost. Measured + // back to back: with this pause the panel went `disabled -> enabled`, without it it did not. + std::thread::sleep(DRM_WAKE_DEVICE_SETTLE); + + // +1 then -1: activity with zero net displacement. emit() appends the SYN_REPORT itself. + let step = |v: i32| { + evdev::InputEvent::new( + evdev::EventType::RELATIVE, + evdev::RelativeAxisType::REL_X.0, + v, + ) + }; + let ok = dev.emit(&[step(1)]).and_then(|_| { + std::thread::sleep(std::time::Duration::from_millis(120)); + dev.emit(&[step(-1)]) + }); + if let Err(err) = ok { + log::warn!("drm: display wake ({reason}) failed to emit: {err}"); + return false; + } + log::info!("drm: no display was scanning out ({reason}); asked the compositor to wake up"); + true +} + +#[cfg(not(feature = "drm-wake"))] +fn drm_enumerate_settled(reason: &str) -> Vec { + let (displays, undriven) = drm_enumerate_all_displays(); + if !undriven.is_empty() { + log::debug!( + "drm: {} connected display(s) have no CRTC ({reason}); this build has no display wake", + undriven.len() + ); + } + displays +} + +/// Wake build: wake an undriven display and WAIT for the settled topology. The wait applies to every +/// handshake whose wake may still be in flight, not only the one whose attempt won the rate limit. +#[cfg(feature = "drm-wake")] +fn drm_enumerate_settled(reason: &str) -> Vec { + use std::sync::atomic::Ordering; + + let (displays, undriven) = drm_enumerate_all_displays(); + if !hbb_common::config::Config::get_bool_option(OPTION_ENABLE_DRM_DISPLAY_WAKE) { + if !undriven.is_empty() { + log::info!( + "drm: {} connected display(s) have no CRTC ({reason}), but the display wake is \ + disabled by configuration ({OPTION_ENABLE_DRM_DISPLAY_WAKE}=N)", + undriven.len() + ); + } + return displays; + } + let wakeable = drm_wakeable_undriven(&displays, &undriven); + if wakeable.is_empty() { + return displays; + } + let fired = drm_wake_displays(&format!( + "{reason} and {n} connected display(s) had no CRTC", + n = wakeable.len() + )); + if !fired { + if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) { + return displays; + } + let last = DRM_LAST_WAKE.load(Ordering::Acquire); + if last == 0 + || drm_wake_clock_secs().saturating_sub(last) > DRM_WAKE_SETTLE_WINDOW.as_secs() + { + return displays; + } + } + let before_len = displays.len(); + let deadline = std::time::Instant::now() + DRM_WAKE_RECHECK_TOTAL; + let mut cur = displays; + let mut cur_wakeable = wakeable; + while !cur_wakeable.is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(300)); + let (next, next_undriven) = drm_enumerate_all_displays(); + cur_wakeable = drm_wakeable_undriven(&next, &next_undriven); + cur = next; + } + if cur.len() > before_len { + log::info!( + "drm: {} display(s) came back after the wake ({} -> {}{})", + cur.len() - before_len, + before_len, + cur.len(), + if cur_wakeable.is_empty() { + String::new() + } else { + format!(", {} still undriven", cur_wakeable.len()) + } + ); + schedule_drm_cache_refresh(); + } + if fired && !cur_wakeable.is_empty() { + // Only the handshake that FIRED latches; a loser's baseline was taken mid-transition. + let mut hopeless = DRM_WAKE_HOPELESS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for id in &cur_wakeable { + if !hopeless.iter().any(|h| h == id) { + hopeless.push(id.clone()); + } + } + log::info!( + "drm: the wake did not bring back {list}; not asking again for {these} until {it_is} \ + seen scanning out", + list = cur_wakeable.join(", "), + these = if cur_wakeable.len() == 1 { "it" } else { "them" }, + it_is = if cur_wakeable.len() == 1 { "it is" } else { "they are" }, + ); + } + cur +} + +/// The SINGLE writer of DRM_DISPLAY_CACHE (+ DRM_DISPLAY_GENERATION), off the caller's thread and +/// SINGLE-FLIGHT: a request arriving during a run coalesces into exactly one follow-up. +fn schedule_drm_cache_refresh() { + use std::sync::atomic::{AtomicBool, Ordering}; + static RUNNING: AtomicBool = AtomicBool::new(false); + static PENDING: AtomicBool = AtomicBool::new(false); + // Ownership of RUNNING, released on every exit incl. unwind and failed spawn; re-taken mid-loop. + struct RefreshSlot(bool); + impl RefreshSlot { + fn release(&mut self) { + if self.0 { + self.0 = false; + RUNNING.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !RUNNING.swap(true, Ordering::AcqRel); + self.0 + } + } + impl Drop for RefreshSlot { + fn drop(&mut self) { + self.release(); + } + } + // Announce a refresh is wanted before trying to run, so an active worker is guaranteed to see it. + PENDING.store(true, Ordering::Release); + if RUNNING.swap(true, Ordering::AcqRel) { + return; // a worker is already active; it will observe PENDING and refresh again + } + let mut slot = RefreshSlot(true); + let spawned = std::thread::Builder::new() + .name("drm-cache-refresh".into()) + .spawn(move || loop { + PENDING.store(false, Ordering::Release); + let fresh = std::panic::catch_unwind(drm_enumerate_all_displays) + .unwrap_or_else(|_| { + log::error!("drm: display enumeration panicked; treating as no displays"); + (Vec::new(), Vec::new()) + }) + .0; + let changed = { + let mut cache = match DRM_DISPLAY_CACHE.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if *cache != fresh { + *cache = fresh; + true + } else { + false + } + }; + if changed { + DRM_DISPLAY_GENERATION.fetch_add(1, Ordering::Release); + log::info!("drm: display cache refreshed (topology changed)"); + } + // Exit only if no request arrived during this enumeration. The re-check after releasing + // the slot closes the lost-wakeup window (a request that set PENDING just before it). + if !PENDING.load(Ordering::Acquire) { + slot.release(); + if !PENDING.load(Ordering::Acquire) { + break; + } + if !slot.retake() { + break; // another caller re-acquired the slot; it will handle the pending refresh + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the display-cache refresh worker: {err}"); + } +} + +fn uevent_is_drm_change(msg: &[u8]) -> bool { + let mut is_drm = false; + let mut is_change = false; + for rec in msg.split(|&b| b == 0) { + if rec == b"SUBSYSTEM=drm" { + is_drm = true; + } else if rec == b"ACTION=change" || rec == b"HOTPLUG=1" { + is_change = true; + } + } + is_drm && is_change +} + +/// Refresh the display cache on DRM hotplug uevents (raw NETLINK_KOBJECT_UEVENT, no libudev). +fn drm_udev_listener() { + use hbb_common::libc; + + let sock = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + if sock < 0 { + log::info!( + "drm: udev uevent socket unavailable ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + let _owned = unsafe { OwnedFd::from_raw_fd(sock) }; + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as u16; + // Group 1 = kernel-originated uevents (udev re-broadcasts on group 2); pid 0 => kernel assigns. + addr.nl_groups = 1; + let rc = unsafe { + libc::bind( + sock, + &addr as *const libc::sockaddr_nl as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc < 0 { + log::info!( + "drm: udev uevent bind failed ({}); hotplug refresh disabled", + std::io::Error::last_os_error() + ); + return; + } + log::info!("drm: udev DRM-uevent listener started"); + let mut buf = [0u8; 8192]; + loop { + // recvmsg, not recv: a local process could UNICAST a spoofed uevent to this root listener. + let mut src: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() }; + mhdr.msg_name = &mut src as *mut libc::sockaddr_nl as *mut libc::c_void; + mhdr.msg_namelen = std::mem::size_of::() as libc::socklen_t; + mhdr.msg_iov = &mut iov; + mhdr.msg_iovlen = 1; + let n = unsafe { libc::recvmsg(sock, &mut mhdr, 0) }; + if n <= 0 { + let err = std::io::Error::last_os_error(); + if n < 0 && err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + log::info!("drm: udev uevent recv ended ({err}); hotplug refresh stopped"); + break; + } + if (mhdr.msg_namelen as usize) < std::mem::size_of::() + || src.nl_pid != 0 + || src.nl_groups == 0 + { + continue; + } + if !uevent_is_drm_change(&buf[..n as usize]) { + continue; + } + schedule_drm_cache_refresh(); + } +} + +fn drm_prewarm() { + // Re-ask, bounded: `get_display_server()` falls back to "x11" when it cannot tell (measured: + // "x11" 0.8 s into a boot on a Wayland host). `scrap::is_x11()` is the UNMEMOISED path. + const PREWARM_SESSION_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); + const PREWARM_SESSION_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); + let waited = std::time::Instant::now(); + while scrap::is_x11() { + if waited.elapsed() >= PREWARM_SESSION_BUDGET { + log::info!( + "drm: session still reads as X11 after {:?}; skipping the pre-warm \ + (the _drm listener still runs)", + PREWARM_SESSION_BUDGET + ); + return; + } + std::thread::sleep(PREWARM_SESSION_RECHECK); + } + let t = std::time::Instant::now(); + schedule_drm_cache_refresh(); + match scrap::drm_reader::DrmReader::open(None, 0) { + Some(mut r) => { + // grab_desc(), not grab(): exports an fd without loading libEGL into the root service. + if let Ok((fd, _desc)) = r.grab_desc() { + drop(fd); // close the warm-up fd; we only wanted to prime the device/import path + } + log::info!("drm: pre-warm framebuffer primed in {:?}", t.elapsed()); + } + None => log::info!("drm: pre-warm skipped (no reader; cache refresh requested)"), + } +} + +/// Capture producer in the ROOT `--service`: one task per consumer, reader on a worker thread. +#[tokio::main(flavor = "current_thread")] +pub async fn start_drm() { + match new_drm_listener() { + Ok(mut incoming) => { + if let Err(err) = std::thread::Builder::new() + .name("drm-prewarm".into()) + .spawn(drm_prewarm) + { + log::warn!("drm: could not spawn the pre-warm thread ({err}); skipping the warmup"); + } + if let Err(err) = std::thread::Builder::new() + .name("drm-udev".into()) + .spawn(drm_udev_listener) + { + log::warn!( + "drm: could not spawn the udev listener ({err}); a mid-session topology change \ + will not be pushed, and consumers pick it up on their next handshake" + ); + } + loop { + match incoming.next().await { + Some(Ok(stream)) => { + tokio::spawn(async move { + if let Err(err) = handle_drm_conn(Connection::new(stream)).await { + log::info!("drm ipc connection ended: {}", err); + } + }); + } + Some(Err(err)) => log::error!("Couldn't get drm client: {:?}", err), + None => { + log::error!("drm ipc listener stream ended; stopping drm producer"); + break; + } + } + } + } + Err(err) => { + log::error!("Failed to start drm ipc server: {}", err); + } + } +} + +const MAX_DRM_CONNS: usize = 8; + +fn drm_conn_admitted(prev_count: usize) -> bool { + prev_count < MAX_DRM_CONNS +} + +const MAX_DRM_AUTH_IN_FLIGHT: usize = 4; + +fn drm_auth_admitted(prev_in_flight: usize) -> bool { + prev_in_flight < MAX_DRM_AUTH_IN_FLIGHT +} + +fn drm_peer_authorized(peer_uid: Option, active_uid: Option) -> bool { + match peer_uid { + Some(0) => true, + Some(uid) => active_uid == Some(uid), + None => false, + } +} + +/// Handle one `_drm` consumer: a private worker thread owns the `!Send` reader; this task forwards. +async fn handle_drm_conn(stream: Connection) -> ResultType<()> { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + // World-connectable socket, so the peer MUST be authorized here (this listener bypasses the + // generic `start()` accept loop). On the blocking pool: a cache miss forks `loginctl`. + static DRM_AUTH_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0); + struct DrmAuthGuard; + impl Drop for DrmAuthGuard { + fn drop(&mut self) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_auth_admitted(DRM_AUTH_IN_FLIGHT.fetch_add(1, Ordering::SeqCst)) { + DRM_AUTH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst); + // Deliberately `debug`, not `warn`: this is reachable by any local uid, so a level that + // reaches the service log on every attempt is an unbounded log-write primitive for that peer. + log::debug!("drm: too many _drm authorizations in flight; dropping this connection"); + return Ok(()); + } + let auth_guard = DrmAuthGuard; + let (stream, authorized) = tokio::task::spawn_blocking(move || { + let ok = authorize_service_scoped_ipc_connection(&stream, "_drm"); + (stream, ok) + }) + .await?; + drop(auth_guard); + if !authorized { + // Deliberately no log here: the call above already reports it -- the uid mismatch through + // `log_rejected_service_connection`, throttled to one line per 5 s, and the executable + // mismatch as a plain warn. A second, unthrottled warn here would be the same unbounded + // log-write primitive. + return Ok(()); + } + + static DRM_CONN_COUNT: AtomicUsize = AtomicUsize::new(0); + struct DrmConnGuard; + impl Drop for DrmConnGuard { + fn drop(&mut self) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + } + } + if !drm_conn_admitted(DRM_CONN_COUNT.fetch_add(1, Ordering::SeqCst)) { + DRM_CONN_COUNT.fetch_sub(1, Ordering::SeqCst); + log::warn!("drm: too many concurrent _drm connections (>= {MAX_DRM_CONNS}); rejecting"); + return Ok(()); + } + let _conn_guard = DrmConnGuard; + + // Re-authorized per frame below: DRM/KMS capture is NOT session-scoped, so unless a stream stops + // when the active session changes the outgoing user's --server keeps receiving the incoming + // user's screen (and the greeter in between). + let peer_uid = stream.peer_uid(); + + let mut conn = dup_to_drm_conn(&stream)?; + drop(stream); + + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); + let (crtc_tx, crtc_rx) = std::sync::mpsc::channel::<(String, u32, bool)>(); + let stop = Arc::new(AtomicBool::new(false)); + let _stop_guard = DrmStopGuard(stop.clone()); + let worker_stop = stop.clone(); + let frames_gated = Arc::new(AtomicBool::new(false)); + let worker_gate = frames_gated.clone(); + std::thread::Builder::new() + .name("drm-capture".into()) + .spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop, worker_gate)) + .map_err(|err| anyhow::anyhow!("could not spawn the drm capture worker: {err}"))?; + + let displays = match frame_rx.recv().await { + Some(DrmProducerMsg::Displays(d)) => d, + _ => { + log::info!("drm: reader unavailable; closing _drm connection (client falls back)"); + return Ok(()); + } + }; + conn.send_msg(&Data::DrmDisplayList(displays.clone()), None).await?; + + let (display_idx, need_cpu) = match conn.recv_msg_timeout2(10_000).await { + Some(Ok((Data::DrmStart { display, need_cpu }, _fd))) => (display, need_cpu), + Some(Ok((_, _fd))) => { + log::info!("drm: peer sent something other than DrmStart in the handshake; closing"); + return Ok(()); + } + Some(Err(e)) => return Err(e), + None => return Ok(()), // timed out: client never chose a display + }; + // Reject crtc 0: `open(crtc=0)` auto-selects the FIRST ACTIVE CRTC and streams the WRONG monitor. + let selected = usize::try_from(display_idx) + .ok() + .and_then(|i| displays.get(i)); + let target_crtc = selected.map(|d| d.crtc_id).unwrap_or(0); + let target_device = selected.map(|d| d.device.clone()).unwrap_or_default(); + if target_crtc == 0 { + log::warn!( + "drm: client selected display {display_idx} with no bound CRTC; closing _drm (client falls back)" + ); + return Ok(()); + } + if crtc_tx.send((target_device, target_crtc, need_cpu)).is_err() { + return Ok(()); + } + + let mut seen_gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + const DRM_FRAME_CREDIT: i32 = 2; + let mut credit: i32 = DRM_FRAME_CREDIT; + let mut credit_since = std::time::Instant::now(); + let mut held_frame: Option = None; + loop { + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + // While gated the worker does not grab, so it cannot advance its own MAX_STALLED watchdog: a + // consumer that stops acking without closing the socket would otherwise hold this connection, + // its worker thread and the privileged DRM context open indefinitely. + const CREDIT_STALL: std::time::Duration = std::time::Duration::from_secs(5); + if credit > 0 { + credit_since = std::time::Instant::now(); + } else if credit_since.elapsed() > CREDIT_STALL { + log::info!("drm: consumer has not acked for {CREDIT_STALL:?}; closing _drm connection"); + break; + } + // This must NOT also require that a frame is already held: those grabs keep the held frame + // fresh (latest-wins below), so gating on "held" would pin whatever frame was in hand when + // credit ran out and ship it stale once the ack lands. + frames_gated.store(credit <= 0, Ordering::Relaxed); + let first: Option = if held_frame.is_some() && credit > 0 { + frame_rx.try_recv().ok() + } else if credit <= 0 { + const CREDIT_POLL: std::time::Duration = std::time::Duration::from_secs(1); + let waited = tokio::time::timeout(CREDIT_POLL, async { + tokio::select! { + biased; + r = conn.wait_readable() => r.map(|_| None), + m = frame_rx.recv() => Ok(Some(m)), + } + }) + .await; + match waited { + Err(_) => None, + Ok(Err(err)) => return Err(err), + Ok(Ok(None)) => None, + Ok(Ok(Some(None))) => break, + Ok(Ok(Some(Some(m)))) => Some(m), + } + } else { + match frame_rx.recv().await { + Some(f) => Some(f), + None => break, + } + }; + // Re-authorize per frame with the CACHE-ONLY active uid: a fresh lookup forks `loginctl` and + // would stall every stream on this single-threaded runtime. A miss is fail-closed for a non-root peer + // (root stays authorized; see `drm_peer_authorized`). + let peer_ok = drm_peer_authorized(peer_uid, active_uid_cached()); + if !peer_ok { + log::warn!("drm: _drm peer no longer matches the active session (or it is unknown); closing"); + break; + } + let gen = DRM_DISPLAY_GENERATION.load(Ordering::Acquire); + if gen != seen_gen { + seen_gen = gen; + let fresh = DRM_DISPLAY_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + // Send even an EMPTY list, or the consumer keeps advertising removed displays. + conn.send_msg(&Data::DrmDisplaysChanged(fresh), None).await?; + } + let mut latest_frame: Option = held_frame.take(); + let mut msg = first.or_else(|| frame_rx.try_recv().ok()); + while let Some(m) = msg.take() { + match m { + f @ (DrmProducerMsg::Frame { .. } | DrmProducerMsg::FrameCpu { .. }) => { + latest_frame = Some(f); + } + DrmProducerMsg::Cursor { + id, + width, + height, + hotx, + hoty, + colors, + } => { + conn.send_msg( + &Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + }, + None, + ) + .await?; + conn.send_raw(Bytes::from(colors)).await?; + } + DrmProducerMsg::Displays(_) => {} + } + msg = frame_rx.try_recv().ok(); + } + conn.drain_frame_acks(&mut credit, DRM_FRAME_CREDIT)?; + if credit <= 0 { + held_frame = latest_frame; + continue; + } + match latest_frame { + Some(DrmProducerMsg::Frame { mut desc, fd }) => { + // Every exported frame carries its fd: the kernel can recycle an fb_id onto another + // buffer with the same geometry/modifier and this side cannot see the dma-buf inode + // that would tell the difference, so eliding it can serve a stale EGLImage. libdrmtap's + // import cache keys on fb_id AND inode, and can only re-import when handed a real fd. + let send_fd = fd.is_some(); + desc.has_fd = send_fd; + let borrowed = if send_fd { fd.as_ref().map(|f| f.as_fd()) } else { None }; + conn.send_msg(&Data::DrmFrameDmabuf(desc), borrowed).await?; + credit -= 1; // one frame in flight until the consumer acks it + // `fd` (OwnedFd) is closed here whether or not it was attached (the cmsg dup'd it + // into the peer), which bounds our fd usage to ~1 in flight per frame. + } + Some(DrmProducerMsg::FrameCpu { + width, + height, + data, + }) => { + conn.send_msg(&Data::DrmFrame { width, height }, None).await?; + conn.send_raw(data).await?; + credit -= 1; // one frame in flight until the consumer acks it + } + _ => {} + } + } + Ok(()) +} + +fn drm_capture_worker( + frame_tx: tokio::sync::mpsc::Sender, + crtc_rx: std::sync::mpsc::Receiver<(String, u32, bool)>, + stop: std::sync::Arc, + frames_gated: std::sync::Arc, +) { + use std::sync::atomic::Ordering; + use std::time::Duration; + const FRAME_INTERVAL: Duration = Duration::from_millis(33); + // Bound continuous no-frame (WouldBlock) time so a wedged device ends the stream (~5 s). + const MAX_STALLED: u32 = 150; + + let t_conn = std::time::Instant::now(); + + // Enumerate FRESH rather than serve the cache: a cached display may no longer be driven. + let displays = drm_enumerate_settled("a consumer connected"); + if frame_tx + .blocking_send(DrmProducerMsg::Displays(displays)) + .is_err() + { + return; + } + + let (target_device, target_crtc, need_cpu) = match crtc_rx.recv() { + Ok(c) => c, + Err(_) => return, + }; + let device_arg = if target_device.is_empty() { + None + } else { + Some(target_device.as_str()) + }; + let t_open = std::time::Instant::now(); + let mut reader = match scrap::drm_reader::DrmReader::open(device_arg, target_crtc) { + Some(r) => r, + None => { + log::warn!( + "drm: failed to open crtc {target_crtc} on {}; closing _drm connection", + if target_device.is_empty() { "auto" } else { &target_device } + ); + schedule_drm_cache_refresh(); + return; + } + }; + schedule_drm_cache_refresh(); + log::debug!( + "drm: capture reader for crtc {target_crtc} opened in {:?}", + t_open.elapsed() + ); + + static DRM_CONN_EPOCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let conn_epoch = DRM_CONN_EPOCH.fetch_add(1, Ordering::Relaxed); + + let mut use_dmabuf = !need_cpu; + + let mut last_cursor_id: u64 = 0; + let mut stalled: u32 = 0; + let mut logged_first = false; + while !stop.load(Ordering::Relaxed) { + let grabbed: Option> = if frames_gated.load(Ordering::Relaxed) + { + // `stalled` is left untouched because the device is healthy -- the task bounds this + // state itself (CREDIT_STALL) since our watchdog cannot advance. + None + } else if use_dmabuf { + Some(match reader.grab_desc() { + Ok((fd, d)) => Ok(DrmProducerMsg::Frame { + desc: DmabufDesc { + buffer_id: (d.fb_id as u64) | ((conn_epoch as u64) << 32), + width: d.width, + height: d.height, + format: d.format, + modifier: d.modifier, + fb_id: d.fb_id, + num_planes: d.num_planes, + offsets: d.offsets, + pitches: d.pitches, + hdr_eotf: d.hdr_eotf, + hdr_max_nits: d.hdr_max_nits, + has_fd: true, // every exported frame carries its fd; see the send below + }, + fd: Some(fd), + }), + Err(err) => Err(err), + }) + } else { + Some(match reader.grab() { + Ok((buf, w, h)) => Ok(DrmProducerMsg::FrameCpu { + width: w as u32, + height: h as u32, + data: Bytes::copy_from_slice(buf), + }), + Err(err) => Err(err), + }) + }; + match grabbed { + None => {} + Some(Ok(msg)) => { + stalled = 0; + if !logged_first { + logged_first = true; + log::debug!( + "drm: first frame for crtc {target_crtc} in {:?} ({} path)", + t_conn.elapsed(), + if use_dmabuf { "dma-buf" } else { "cpu" } + ); + } + if frame_tx.blocking_send(msg).is_err() { + break; + } + } + Some(Err(err)) if err.kind() == std::io::ErrorKind::WouldBlock => { + stalled += 1; + if stalled > MAX_STALLED { + log::info!("drm: capture stalled (no frame); closing _drm connection"); + break; + } + std::thread::sleep(FRAME_INTERVAL); + continue; + } + Some(Err(err)) if use_dmabuf && err.kind() == std::io::ErrorKind::Unsupported => { + log::warn!( + "drm: grab_desc unsupported ({err}); switching to CPU-mapped fallback for this connection" + ); + use_dmabuf = false; + logged_first = false; + // The stall counter measured the abandoned path; give the fallback the whole budget. + stalled = 0; + continue; + } + Some(Err(err)) => { + log::warn!("drm: capture error: {err}; closing _drm connection"); + break; + } + } + + // Ship the cursor shape only when it changes (id is a content hash or the hidden sentinel). + if let Some(c) = reader.cursor() { + if c.id != last_cursor_id { + last_cursor_id = c.id; + if frame_tx + .blocking_send(DrmProducerMsg::Cursor { + id: c.id, + width: c.width, + height: c.height, + hotx: c.hotx, + hoty: c.hoty, + colors: c.colors, + }) + .is_err() + { + break; + } + } + } + + std::thread::sleep(FRAME_INTERVAL); + } +} + +/// Ancillary-fd transport for `_drm`: `Framed`/`BytesCodec` cannot carry an SCM_RIGHTS cmsg, so the +/// messages and raw bodies use a 4-byte big-endian length + payload, with any fd bound to the first + /// byte. The reverse-direction frame acks are bare bytes, not framed. +pub(crate) struct DrmConn { + stream: tokio::net::UnixStream, + read_buf: Vec, + /// Set once the current read consumed a byte: a spurious `readable()` vs a mid-frame stall. + consumed: bool, +} + +const MAX_DRM_JSON_BYTES: usize = 8 * 1024 * 1024; +const DRM_BODY_TIMEOUT_MS: u64 = 5_000; +const DRM_SEND_TIMEOUT_MS: u64 = 5_000; + +const MAX_DRM_RAW_BYTES: usize = 512 * 1024 * 1024; +/// `CMSG_SPACE(sizeof(int))` is 24 bytes on our targets; 64 gives headroom and the `align(8)` +/// matches `cmsghdr` alignment. +const DRM_CMSG_CAP: usize = 64; + +/// Aligned storage for the SCM_RIGHTS control buffer (`msg_control` must be `cmsghdr`-aligned). +#[repr(align(8))] +struct DrmCmsgBuf([u8; DRM_CMSG_CAP]); + +/// One non-blocking `sendmsg`; the cmsg is attached ONLY when a fd is present (-1 fails the call). +/// SAFETY: `fd` a valid open socket fd, `buf` a readable slice, `pass_fd` (if any) a valid open fd. +unsafe fn drm_sendmsg(fd: RawFd, buf: &[u8], pass_fd: Option) -> std::io::Result { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + if let Some(sfd) = pass_fd { + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::() as u32) as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + if cmsg.is_null() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: CMSG_FIRSTHDR null", + )); + } + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::() as u32) as _; + let sfd_c: libc::c_int = sfd; + std::ptr::copy_nonoverlapping( + &sfd_c as *const libc::c_int as *const u8, + libc::CMSG_DATA(cmsg), + std::mem::size_of::(), + ); + } + let n = libc::sendmsg(fd, &msg, libc::MSG_NOSIGNAL); + if n < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +/// One non-blocking `recvmsg`: keeps at most one SCM_RIGHTS fd (surplus closed), rejects MSG_CTRUNC. +/// SAFETY: `fd` must be a valid open socket fd; `buf` a valid writable slice. +unsafe fn drm_recvmsg(fd: RawFd, buf: &mut [u8]) -> std::io::Result<(usize, Option)> { + use hbb_common::libc; + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + let mut cbuf = DrmCmsgBuf([0u8; DRM_CMSG_CAP]); + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.0.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = cbuf.0.len() as _; + let n = libc::recvmsg(fd, &mut msg, libc::MSG_CMSG_CLOEXEC); + if n < 0 { + return Err(std::io::Error::last_os_error()); + } + let mut got: Option = None; + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS { + let data = libc::CMSG_DATA(cmsg); + let hdr = libc::CMSG_LEN(0) as usize; + let payload = ((*cmsg).cmsg_len as usize).saturating_sub(hdr); + let count = payload / std::mem::size_of::(); + for i in 0..count { + let mut rawfd: libc::c_int = -1; + std::ptr::copy_nonoverlapping( + data.add(i * std::mem::size_of::()), + &mut rawfd as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + if rawfd >= 0 { + let owned = OwnedFd::from_raw_fd(rawfd); + if got.is_none() { + got = Some(owned); + } // else: surplus fd, dropped here -> closed + } + } + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + if msg.msg_flags & libc::MSG_CTRUNC != 0 { + drop(got); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "drm: truncated SCM_RIGHTS control message (MSG_CTRUNC)", + )); + } + Ok((n as usize, got)) +} + +async fn drm_write_all( + stream: &tokio::net::UnixStream, + mut buf: &[u8], + mut pass_fd: Option, +) -> ResultType<()> { + // ONE deadline for the whole write: arming it per readiness wait lets a dripping peer re-arm it. + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + while !buf.is_empty() { + match tokio::time::timeout_at(deadline, stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: peer did not accept the remaining {} byte(s) within {DRM_SEND_TIMEOUT_MS}ms; closing", + buf.len() + ), + } + let raw = stream.as_raw_fd(); + let chunk = buf; + let fd_now = pass_fd; + match stream.try_io(tokio::io::Interest::WRITABLE, || unsafe { + drm_sendmsg(raw, chunk, fd_now) + }) { + Ok(0) => bail!("drm: socket write returned 0 (peer closed)"), + Ok(n) => { + pass_fd = None; // ancillary delivered with these bytes; do not re-send it + buf = &buf[n..]; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + Ok(()) +} + +async fn drm_send_frame( + stream: &tokio::net::UnixStream, + payload: &[u8], + pass_fd: Option, +) -> ResultType<()> { + if payload.len() > u32::MAX as usize { + bail!("drm: frame too large ({} bytes)", payload.len()); + } + let prefix = (payload.len() as u32).to_be_bytes(); + drm_write_all(stream, &prefix, pass_fd).await?; + drm_write_all(stream, payload, None).await?; + Ok(()) +} + +async fn drm_read_full( + stream: &tokio::net::UnixStream, + buf: &mut [u8], + want_cmsg: bool, + progress: &mut bool, +) -> ResultType> { + use hbb_common::libc; + let mut off = 0usize; + let mut got: Option = None; + while off < buf.len() { + stream.readable().await?; + let raw = stream.as_raw_fd(); + let use_cmsg = want_cmsg && got.is_none(); + let n = { + let dst: &mut [u8] = &mut buf[off..]; + match stream.try_io(tokio::io::Interest::READABLE, move || unsafe { + if use_cmsg { + drm_recvmsg(raw, dst) + } else { + let m = libc::read(raw, dst.as_mut_ptr() as *mut libc::c_void, dst.len()); + if m < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok((m as usize, None)) + } + } + }) { + Ok((0, _fd)) => bail!("drm: socket closed by peer"), + Ok((m, fd)) => { + if let Some(f) = fd { + if got.is_none() { + got = Some(f); + } + } + m + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + }; + // Any byte off the socket commits us to this frame: a cancellation cannot be re-polled. + if n > 0 { + *progress = true; + } + off += n; + } + Ok(got) +} + +impl DrmConn { + pub fn new(stream: tokio::net::UnixStream) -> Self { + Self { + stream, + read_buf: Vec::new(), + consumed: false, + } + } + + pub async fn send_msg(&mut self, data: &Data, fd: Option>) -> ResultType<()> { + let payload = serde_json::to_vec(data)?; + let pass_fd = fd.map(|f| f.as_raw_fd()); + drm_send_frame(&self.stream, &payload, pass_fd).await + } + + pub async fn send_frame_ack(&self) -> ResultType<()> { + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS); + loop { + match tokio::time::timeout_at(deadline, self.stream.writable()).await { + Ok(r) => r?, + Err(_) => bail!( + "drm: _drm frame-ack was not accepted within {DRM_SEND_TIMEOUT_MS}ms; closing" + ), + } + match self.stream.try_write(&[1u8]) { + Ok(n) if n > 0 => return Ok(()), + Ok(_) => bail!("drm: _drm frame-ack write returned 0 (peer closed)"), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e.into()), + } + } + } + + pub fn drain_frame_acks(&self, credit: &mut i32, max: i32) -> ResultType<()> { + let mut buf = [0u8; 64]; + // BOUNDED: "until WouldBlock" is the peer's promise; a continuous writer would pin us. + const MAX_ACK_READS: usize = 64; + for _ in 0..MAX_ACK_READS { + match self.stream.try_read(&mut buf) { + Ok(0) => bail!("drm: _drm frame-ack peer closed"), + Ok(n) => { + *credit = (*credit + n as i32).min(max); + if *credit >= max { + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(()), + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + pub async fn wait_readable(&self) -> ResultType<()> { + self.stream.readable().await?; + Ok(()) + } + + pub async fn recv_msg(&mut self) -> ResultType<(Data, Option)> { + self.consumed = false; + let mut prefix = [0u8; 4]; + let fd = drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed).await?; + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_JSON_BYTES { + bail!("drm: message length {len} exceeds cap {MAX_DRM_JSON_BYTES}"); + } + if self.read_buf.len() < len { + self.read_buf.resize(len, 0); + } + drm_read_full(&self.stream, &mut self.read_buf[..len], false, &mut self.consumed).await?; + let data: Data = serde_json::from_slice(&self.read_buf[..len])?; + Ok((data, fd)) + } + + /// Cancel-safe timeout wrapper around `recv_msg`. `None` = nothing consumed, so re-polling is + /// safe; past the first byte the frame is committed and an overrun is a hard error. + pub async fn recv_msg_timeout2( + &mut self, + ms_timeout: u64, + ) -> Option)>> { + let ready = timeout(ms_timeout, self.stream.readable()).await; + match ready { + Err(_) => None, // no frame started: clean boundary, caller re-checks `stop` + Ok(Err(e)) => Some(Err(e.into())), + Ok(Ok(())) => match timeout(ms_timeout, self.recv_msg()).await { + Ok(res) => Some(res), + Err(_) if self.consumed => Some(Err(anyhow::anyhow!( + "drm: frame body stalled past {ms_timeout}ms after first byte; closing" + ))), + Err(_) => None, + }, + } + } + + pub async fn send_raw(&mut self, data: Bytes) -> ResultType<()> { + drm_send_frame(&self.stream, &data, None).await + } + + pub async fn next_raw_into(&mut self, out: &mut Vec) -> ResultType<()> { + match timeout(DRM_BODY_TIMEOUT_MS, self.next_raw_into_unbounded(out)).await { + Ok(res) => res, + Err(_) => bail!( + "drm: raw body did not arrive within {DRM_BODY_TIMEOUT_MS}ms of its header; closing" + ), + } + } + + async fn next_raw_into_unbounded(&mut self, out: &mut Vec) -> ResultType<()> { + let mut prefix = [0u8; 4]; + if drm_read_full(&self.stream, &mut prefix, true, &mut self.consumed) + .await? + .is_some() + { + log::warn!("drm: unexpected fd on a raw-body frame; dropping"); + } + let len = u32::from_be_bytes(prefix) as usize; + if len > MAX_DRM_RAW_BYTES { + bail!("drm: raw body length {len} exceeds cap {MAX_DRM_RAW_BYTES}"); + } + out.resize(len, 0); + drm_read_full(&self.stream, &mut out[..], false, &mut self.consumed).await?; + Ok(()) + } +} + +#[cfg(test)] +mod drm_conn_tests { + use super::*; + use hbb_common::libc; + use hbb_common::tokio::{self, io::AsyncWriteExt}; + use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; + + // Added to the wire later: an older peer's message must still decode. + #[test] + fn drm_display_info_decodes_without_render_node() { + let legacy = r#"{"name":"DP-1","crtc_id":386,"x":0,"y":0, + "width":3840,"height":2160,"active":true}"#; + let info: DrmDisplayInfo = + serde_json::from_str(legacy).expect("a pre-render_node payload must still decode"); + assert_eq!(info.name, "DP-1"); + assert_eq!(info.crtc_id, 386); + assert!(info.render_node.is_empty(), "missing node; the consumer auto-selects only where there is one render node"); + assert!(info.device.is_empty(), "missing device means auto-detect"); + + let current = DrmDisplayInfo { + name: "DP-1".to_owned(), + crtc_id: 386, + x: 0, + y: 0, + width: 3840, + height: 2160, + active: true, + render_node: "/dev/dri/renderD129".to_owned(), + device: "/dev/dri/card2".to_owned(), + }; + let wire = serde_json::to_vec(¤t).unwrap(); + let back: DrmDisplayInfo = serde_json::from_slice(&wire).unwrap(); + assert_eq!(back, current); + } + + fn pipe() -> (OwnedFd, OwnedFd) { + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe() failed"); + unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) } + } + + unsafe fn send_with_fds(sock: libc::c_int, data: &[u8], fds: &[libc::c_int]) -> isize { + let mut iov = libc::iovec { + iov_base: data.as_ptr() as *mut libc::c_void, + iov_len: data.len(), + }; + let fdbytes = fds.len() * std::mem::size_of::(); + let space = libc::CMSG_SPACE(fdbytes as u32) as usize; + let mut cbuf = vec![0u8; space]; + let mut msg: libc::msghdr = std::mem::zeroed(); + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = space as _; + let cmsg = libc::CMSG_FIRSTHDR(&msg); + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = libc::CMSG_LEN(fdbytes as u32) as _; + std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fdbytes); + libc::sendmsg(sock, &msg, 0) + } + + #[tokio::test] + async fn roundtrip_msg_no_fd() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + tx.send_msg(&Data::DrmFrame { width: 1920, height: 1080 }, None) + .await + .unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 1920, + height: 1080 + } + )); + assert!(fd.is_none(), "no fd was sent, none must be reported"); + } + + #[tokio::test] + async fn roundtrip_msg_with_fd_identity() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + tx.send_msg(&Data::DrmFrame { width: 4, height: 4 }, Some(rd.as_fd())) + .await + .unwrap(); + let (_data, fd) = rx.recv_msg().await.unwrap(); + let recv_fd = fd.expect("an fd was attached, it must be received"); + let sentinel = [0xABu8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(recv_fd.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0xAB, "received fd must be the same pipe"); + } + + #[tokio::test] + async fn roundtrip_raw_body() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut tx = DrmConn::new(a); + let mut rx = DrmConn::new(b); + let body = Bytes::from(vec![7u8; 5000]); + tx.send_raw(body.clone()).await.unwrap(); + let mut got = Vec::new(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &body[..]); + let short = Bytes::from(vec![9u8; 10]); + tx.send_raw(short.clone()).await.unwrap(); + rx.next_raw_into(&mut got).await.unwrap(); + assert_eq!(&got[..], &short[..]); + } + + #[tokio::test] + async fn rejects_oversized_length_prefix() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let bogus = (MAX_DRM_JSON_BYTES as u32 + 1).to_be_bytes(); + a.write_all(&bogus).await.unwrap(); + let err = rx + .recv_msg() + .await + .err() + .expect("a length past the cap must be rejected"); + assert!( + err.to_string().contains("exceeds cap"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_body_that_never_arrives_times_out() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + a.write_all(&10u32.to_be_bytes()).await.unwrap(); + let mut got = Vec::new(); + let err = rx + .next_raw_into(&mut got) + .await + .err() + .expect("a body that never arrives must time out"); + assert!( + err.to_string().contains("did not arrive"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn a_dripping_peer_cannot_re_arm_the_send_deadline() { + use tokio::io::AsyncReadExt; + let (mut reader, writer) = tokio::net::UnixStream::pair().unwrap(); + let payload = vec![0u8; 32 * 1024 * 1024]; + // Measured: 1 KiB drains do not re-assert POLLOUT; 64 KiB does, which separates the forms. + let drip = tokio::spawn(async move { + let mut sink = vec![0u8; 64 * 1024]; + loop { + if reader.read(&mut sink).await.unwrap_or(0) == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }); + let started = std::time::Instant::now(); + let outcome = tokio::time::timeout( + std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 4), + drm_write_all(&writer, &payload, None), + ) + .await; + drip.abort(); + let inner = outcome.expect( + "the send deadline did not fire: the budget is being re-armed per readiness wait", + ); + let err = inner.err().expect("a dripping peer must not complete the write"); + assert!( + err.to_string().contains("did not accept the remaining"), + "unexpected error: {err}" + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(DRM_SEND_TIMEOUT_MS * 3), + "took {:?}, which is not the send deadline firing", + started.elapsed() + ); + } + + #[tokio::test] + async fn surplus_fds_keep_only_the_first() { + let (mut a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, wr) = pipe(); + let (rd2, _wr2) = pipe(); + let payload = serde_json::to_vec(&Data::DrmFrame { + width: 8, + height: 8, + }) + .unwrap(); + let prefix = (payload.len() as u32).to_be_bytes(); + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &[rd.as_raw_fd(), rd2.as_raw_fd()]) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + a.write_all(&payload).await.unwrap(); + let (data, fd) = rx.recv_msg().await.unwrap(); + assert!(matches!( + data, + Data::DrmFrame { + width: 8, + height: 8 + } + )); + let kept = fd.expect("the first surplus fd must be kept"); + let sentinel = [0x5Au8]; + assert_eq!( + unsafe { libc::write(wr.as_raw_fd(), sentinel.as_ptr() as *const libc::c_void, 1) }, + 1 + ); + let mut got = [0u8; 1]; + assert_eq!( + unsafe { libc::read(kept.as_raw_fd(), got.as_mut_ptr() as *mut libc::c_void, 1) }, + 1 + ); + assert_eq!(got[0], 0x5A, "the kept fd must be the FIRST one sent"); + } + + // 16 fds need CMSG_LEN(64)=80 > the 64-byte DRM_CMSG_CAP, so the kernel sets MSG_CTRUNC. + #[tokio::test] + async fn rejects_truncated_control_message() { + let (a, b) = tokio::net::UnixStream::pair().unwrap(); + let mut rx = DrmConn::new(b); + let (rd, _wr) = pipe(); + let dups: Vec = (0..16).map(|_| rd.try_clone().unwrap()).collect(); + let fds: Vec = dups.iter().map(|f| f.as_raw_fd()).collect(); + let prefix = 0u32.to_be_bytes(); // the fds ride the prefix read; CTRUNC fires before any body + let n = unsafe { send_with_fds(a.as_raw_fd(), &prefix, &fds) }; + assert!(n >= 0, "sendmsg failed: {}", std::io::Error::last_os_error()); + let err = rx + .recv_msg() + .await + .err() + .expect("a truncated control message must be rejected"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("truncat") || msg.contains("ctrunc"), + "unexpected error: {err}" + ); + } + + #[test] + fn peer_uid_from_fd_reads_socket_peer() { + let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); + let euid = unsafe { libc::geteuid() }; + assert_eq!(peer_uid_from_fd(a.as_raw_fd()), Some(euid)); + } + + #[test] + fn drm_peer_authorized_matrix() { + assert!(drm_peer_authorized(Some(0), Some(1000))); + assert!(drm_peer_authorized(Some(0), None)); + assert!(drm_peer_authorized(Some(1000), Some(1000))); + assert!(!drm_peer_authorized(Some(1000), Some(1001))); + assert!(!drm_peer_authorized(Some(1000), None)); + assert!(!drm_peer_authorized(None, Some(1000))); + assert!(!drm_peer_authorized(None, None)); + } + + #[test] + fn accept_time_exe_match_accepts_only_our_own_executable() { + let me = std::process::id(); + assert!( + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(me), "_drm").is_ok(), + "the test process must match its own executable" + ); + + let mut other = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("/bin/sleep should be spawnable in the test environment"); + // Until the child finishes exec'ing, /proc//exe still points at OUR binary. + let ours = std::fs::read_link(format!("/proc/{me}/exe")).ok(); + let peer_link = format!("/proc/{}/exe", other.id()); + let mut exec_done = false; + for _ in 0..200 { + match std::fs::read_link(&peer_link) { + Ok(p) if Some(&p) != ours.as_ref() => { + exec_done = true; + break; + } + _ => std::thread::sleep(std::time::Duration::from_millis(10)), + } + } + let res = if exec_done { + super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(Some(other.id()), "_drm") + } else { + Err(anyhow::anyhow!("child never exec'd; nothing was tested")) + }; + let _ = other.kill(); + let _ = other.wait(); + assert!(exec_done, "the spawned child never exec'd, so the negative case was not exercised"); + assert!( + res.is_err(), + "a peer running another executable must be rejected, got {res:?}" + ); + + assert!(super::ipc_auth::ensure_peer_executable_matches_current_by_pid_opt(None, "_drm").is_err()); + } + + #[test] + fn drm_conn_admission_bound() { + assert!(drm_conn_admitted(0)); + assert!(drm_conn_admitted(MAX_DRM_CONNS - 1)); // last admitted slot + assert!(!drm_conn_admitted(MAX_DRM_CONNS)); // cap reached -> rejected + assert!(!drm_conn_admitted(MAX_DRM_CONNS + 5)); // over cap -> rejected + } + + #[test] + fn drm_auth_admission_bound() { + assert!(drm_auth_admitted(0)); + assert!(drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT - 1)); // last admitted slot + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT)); // cap reached -> rejected + assert!(!drm_auth_admitted(MAX_DRM_AUTH_IN_FLIGHT + 5)); // over cap -> rejected + assert!( + MAX_DRM_AUTH_IN_FLIGHT <= MAX_DRM_CONNS, + "the pre-auth bound must not be looser than the connection cap" + ); + } +} diff --git a/src/ipc/fs.rs b/src/ipc/fs.rs index e0157f3a9..2472ecc83 100644 --- a/src/ipc/fs.rs +++ b/src/ipc/fs.rs @@ -164,9 +164,25 @@ fn scrub_preexisting_ipc_parent_entries( Ok(()) } -fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { - let path = config::Config::ipc_path(postfix); - let parent_dir = Path::new(&path) +/// Remove one entry from the IPC parent directory through a no-follow fd on that directory. +/// +/// Prefer this over `std::fs::remove_file` for anything about to be bound: `remove_file` is +/// `unlink(2)`, which returns EISDIR against a directory-typed squatter and leaves it in place, +/// and the bind that follows then fails EADDRINUSE. `remove_parent_entry_via_fd` fstats the +/// entry first and picks `AT_REMOVEDIR` when it needs to. +/// +/// `AT_REMOVEDIR` is `rmdir(2)`, so the directory case this closes is the EMPTY one; a non-empty +/// squatter still yields ENOTEMPTY and still blocks the bind that follows. That is deliberate, and +/// the "obvious" fix is worse than the bug: removing it recursively would be root deleting a tree +/// an unprivileged process planted. What the caller gains there is a named error to log ahead of +/// the bind's own failure, not a successful bind. +pub(crate) fn remove_ipc_entry_via_secure_parent_fd(path: &str) -> ResultType<()> { + let entry_name = Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))? + .to_owned(); + let parent_dir = Path::new(path) .parent() .ok_or_else(|| Error::new(ErrorKind::InvalidInput, format!("invalid ipc path: {path}")))?; let parent_c = CString::new(parent_dir.as_os_str().as_bytes().to_vec())?; @@ -179,8 +195,8 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { return Err(Error::new( open_err.kind(), format!( - "failed to open ipc parent dir for stale socket cleanup (no-follow): postfix={}, parent={}, err={}", - postfix, + "failed to open ipc parent dir for stale socket cleanup (no-follow): path={}, parent={}, err={}", + path, parent_dir.display(), open_err ), @@ -189,7 +205,11 @@ fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { } }; let _fd_guard = FdGuard(fd); - remove_parent_entry_via_fd(fd, parent_dir, &format!("ipc{}", postfix)) + remove_parent_entry_via_fd(fd, parent_dir, &entry_name) +} + +fn remove_ipc_socket_via_secure_parent_fd(postfix: &str) -> ResultType<()> { + remove_ipc_entry_via_secure_parent_fd(&config::Config::ipc_path(postfix)) } // Purpose: @@ -686,6 +706,64 @@ pub(crate) fn should_scrub_parent_entries_after_check_pid( #[cfg(test)] mod tests { + // Pins the HELPER's contract, which is all `new_drm_listener` consists of at that line -- not + // the call site itself. Binding the real `/tmp/-service/ipc_drm` from a test would collide + // with a live root service, so "the listener still calls this" is not covered here. + #[test] + fn test_remove_ipc_entry_via_secure_parent_fd_clears_an_empty_directory_squatter() { + let unique = format!( + "rustdesk-ipc-entry-remove-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let base = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&base).unwrap(); + let squatter = base.join("ipc_drm"); + std::fs::create_dir(&squatter).unwrap(); + + // Positive control for the defect this closes: `remove_file` is `unlink(2)` and cannot + // remove a directory. That is why the listener could not clear one, and then failed to + // bind over it. Without this line a passing test would prove nothing. + assert!( + std::fs::remove_file(&squatter).is_err(), + "remove_file must fail on a directory, or this test is vacuous" + ); + assert!(squatter.is_dir()); + + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + assert!( + !squatter.exists(), + "the fd-based removal picks AT_REMOVEDIR and clears it" + ); + + // Idempotent: this runs before every bind, so a path that is already gone is not an error. + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + + // The ORDINARY case, and the one the listener hits on every restart: a stale socket left by + // the previous run, i.e. a regular file. Covered here because the other file-removal test + // goes through `remove_parent_entry_via_fd` and the postfix path, not this entry point. + std::fs::write(&squatter, b"stale").unwrap(); + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()).unwrap(); + assert!(!squatter.exists(), "a stale regular file is cleared too"); + + // And the documented limit, pinned so the doc cannot drift: AT_REMOVEDIR is rmdir(2), so a + // NON-empty squatter is reported, not cleared. The caller logs that and carries on; nothing + // here should ever start deleting a tree it did not create. + std::fs::create_dir(&squatter).unwrap(); + std::fs::write(squatter.join("planted"), b"x").unwrap(); + assert!( + super::remove_ipc_entry_via_secure_parent_fd(squatter.to_string_lossy().as_ref()) + .is_err(), + "a non-empty directory must be reported, not silently left as success" + ); + assert!(squatter.join("planted").exists(), "and not deleted"); + + std::fs::remove_dir_all(&base).ok(); + } + #[test] fn test_write_pid_file_rejects_symlink() { use std::os::unix::fs::symlink; diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 06cee3092..68a005ff7 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -361,6 +361,30 @@ pub fn get_focused_display(displays: Vec) -> Option { } pub fn get_cursor() -> ResultType> { + // DRM/KMS capture: the hardware cursor arrives over the `_drm` stream, not from XFixes. + // + // The MEMOISED `is_x11()` here, deliberately, unlike the capture-path callers that take the + // unmemoised `scrap::is_x11()` because this one latches on first use. The tradeoff is the other + // way round at cursor cadence: the unmemoised form forks `loginctl` per call, and this runs on + // every cursor poll. A latch that guessed wrong costs a cursor served by the wrong source until + // the process restarts, not a capture that cannot start -- and by the time a cursor is being + // polled there is a live session, which is the case the latch reads correctly. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(id) = crate::server::drm_capturer::drm_cursor_id() { + // In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays; + // when the pointer sits on a PipeWire-served display every DRM stream reports the hidden + // sentinel. Returning that sentinel here would hide the cursor globally, including on the + // PipeWire display where it is still visible, so only report a hidden DRM cursor when it + // is authoritative -- a pure-DRM session. A visible DRM cursor is always authoritative; + // otherwise fall through to the normal cursor path. + if id != scrap::drm_reader::HIDDEN_CURSOR_ID + || !crate::server::display_service::has_non_drm_backed_display() + { + return Ok(Some(id)); + } + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(d) = conn.try_borrow_mut() { @@ -379,6 +403,32 @@ pub fn get_cursor() -> ResultType> { } pub fn get_cursor_data(hcursor: u64) -> ResultType { + // DRM/KMS capture: return the latest hardware-cursor snapshot from the `_drm` stream. Its id may + // have advanced past `hcursor` between get_cursor() and here, so return the latest rather than + // bailing (which would trigger a MouseCursorService backoff). + // + // Memoised `is_x11()` on purpose, for the reason spelled out in `get_cursor()`; the two must + // agree anyway, since a caller that took the DRM branch there has to take it here. + #[cfg(feature = "drm")] + if !is_x11() { + if let Some(c) = crate::server::drm_capturer::drm_cursor() { + // See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In + // a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served + // by the normal path instead of being hidden everywhere. + if c.id != scrap::drm_reader::HIDDEN_CURSOR_ID + || !crate::server::display_service::has_non_drm_backed_display() + { + let mut cd: CursorData = Default::default(); + cd.id = c.id; + cd.width = c.width; + cd.height = c.height; + cd.hotx = c.hotx; + cd.hoty = c.hoty; + cd.colors = c.colors.into(); + return Ok(cd); + } + } + } let mut res = None; DISPLAY.with(|conn| { if let Ok(ref mut d) = conn.try_borrow_mut() { @@ -680,6 +730,40 @@ fn start_server(desktop: Option<&Desktop>, server: &mut Option) { } } +/// Whether a just-spawned `--server` is still running after a short grace period, taking ownership of +/// the corpse (clearing `server`) when it is not. `start_server` reports only whether the SPAWN +/// succeeded, which is not the same question: a child that execs and exits immediately still leaves +/// `Some(child)` behind. +/// +/// A child that exits is detected as soon as it does; a healthy one costs the full grace, once per +/// start. A server that dies LATER than this is a different (transient) failure, and the restart +/// throttle in `should_start_server` already bounds that case. +#[cfg(feature = "drm")] +fn server_survived_grace(server: &mut Option) -> bool { + const GRACE: Duration = Duration::from_millis(1000); + const STEP_MS: u64 = 100; + let Some(ps) = server.as_mut() else { + return false; // spawn itself failed + }; + let deadline = Instant::now() + GRACE; + while Instant::now() < deadline { + match ps.try_wait() { + Ok(Some(status)) => { + log::warn!("--server exited {status} within {GRACE:?} of starting"); + *server = None; + return false; + } + Ok(None) => sleep_millis(STEP_MS), + // We cannot tell; treat it as alive rather than tearing down a possibly healthy child. + Err(err) => { + log::error!("error waiting on the just-started --server: {err}"); + return true; + } + } + } + true +} + fn stop_server(server: &mut Option) { if let Some(mut ps) = server.take() { allow_err!(ps.kill()); @@ -810,6 +894,29 @@ pub fn start_os_service() { allow_err!(crate::ipc::start(crate::POSTFIX_SERVICE)); }); + // DRM/KMS capture producer (opt-in `drm` feature): a dedicated thread + runtime that streams + // scanout frames to the user `--server` over the `_drm` service-scoped channel. Runs here + // because this process is the root service that already holds CAP_SYS_ADMIN for the in-process + // (direct-mode) libdrmtap read. + // + // Builder, like every other thread this feature starts: `thread::spawn` PANICS if the thread + // cannot be created (EAGAIN under a thread-count or memory limit), and here that panic would + // unwind out of `start_os_service` -- taking down the root service itself, for a feature whose + // failure should only cost DRM capture. Losing the producer leaves the consumer to fall back to + // PipeWire/X11, which is the same path a host without the feature takes. + #[cfg(feature = "drm")] + if let Err(err) = std::thread::Builder::new() + .name("drm-producer".into()) + .spawn(|| { + crate::ipc::start_drm(); + }) + { + log::warn!( + "failed to spawn the drm capture producer thread: {err}; DRM capture is off for \ + this boot and the consumer falls back to PipeWire/X11" + ); + } + let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let (mut display, mut xauth): (String, String) = ("".to_owned(), "".to_owned()); @@ -848,7 +955,38 @@ pub fn start_os_service() { ) { stop_subprocess(); force_stop_server(); + // Run the login-screen --server as the active seat0 session user (the greeter + // account) rather than root, so the DRM capture GPU/EGL convert never loads the + // vendor GPU userspace in a privileged process. is_login_wayland() matches a GDM or + // SDDM Wayland greeter (is_gdm_user covers both), and desktop.uid is that greeter's + // uid, so this drops to whichever greeter owns seat0. A greeter is_gdm_user does not + // recognize (e.g. LightDM) never reaches this branch -- it takes the unprivileged + // else-branch below already. A genuine root graphical session (username=="root") + // has no lower uid to drop to, so it stays root. The whole branch is gated on the drm + // feature, so the drm-off build is upstream's single `start_server(None, ..)` line. + #[cfg(not(feature = "drm"))] start_server(None, &mut server); + #[cfg(feature = "drm")] + if desktop.username != "root" && !desktop.uid.is_empty() { + start_server(Some(&desktop), &mut server); + // If dropping to the greeter uid did not produce a RUNNING server, fall back to a + // root --server so the login screen stays remotable instead of looping on a + // failing greeter spawn. This pays the GPU-in-root tradeoff only on that failure + // path, never in the normal greeter case. Liveness, not just spawn success: a + // greeter account that cannot actually run it (a nologin shell, a hardened home, + // no writable config dir) leaves a child that exits at once, and the loop above + // notices only that the child is gone and respawns it, forever, without ever + // reaching this fallback -- so the login screen becomes permanently un-remotable + // on a host where it used to work. + if !server_survived_grace(&mut server) { + log::warn!( + "greeter --server did not stay up; falling back to a root --server" + ); + start_server(None, &mut server); + } + } else { + start_server(None, &mut server); + } } } else if desktop.username != "" { // try kill subprocess "--server" @@ -927,6 +1065,15 @@ pub fn get_active_userid_fresh() -> String { get_values_of_seat0(&[1])[0].clone() } +#[inline] +/// The cached active uid as a number, or `None` when the cache is empty. Unlike `get_active_userid` +/// this NEVER falls back to a blocking `loginctl` seat0 lookup, so it is safe to call on an async +/// runtime thread and on a hot path (e.g. per-frame re-auth): a cache miss returns `None` for the +/// caller to treat as "active session momentarily unknown" rather than stalling on a subprocess. +pub fn get_active_userid_cached() -> Option { + get_active_user_id_name_from_cache().and_then(|(uid, _)| uid.parse::().ok()) +} + fn get_cm() -> bool { // We use `CMD_PS` instead of `ps` to suppress some audit messages on some systems. if let Ok(output) = Command::new(CMD_PS.as_str()).args(vec!["aux"]).output() { @@ -1939,6 +2086,22 @@ mod desktop { self.display = "".to_owned(); self.xauth = "".to_owned(); self.is_rustdesk_subprocess = false; + // Resolve HOME even on this path. Upstream returned without it because nothing then + // consumed a login-Wayland Desktop, but the drm build starts a `--server` as the + // greeter uid here, and a child with no HOME has nowhere to put its config. The + // compositor variables (WAYLAND_DISPLAY, DBUS, DISPLAY, XAUTHORITY) are left blank + // on purpose and are NOT an oversight: the drm capture path talks to the root + // service over `_drm` and to a render node, never to the compositor or the portal, + // which is the entire reason it works at a login screen. `try_start_server_` skips + // empty entries, so the greeter child simply does not get them. + // + // `is_login_wayland` needs `is_gdm_user(username)`, and a current GDM runs its + // greeter as `gdm-greeter`, which that helper does not match -- measured on the + // test host, where the greeter server therefore takes the branch below and gets a + // fully populated environment. This is for the display managers whose greeter user + // does match. + #[cfg(feature = "drm")] + self.get_home(); return; } diff --git a/src/server.rs b/src/server.rs index f02a15a7f..5af982772 100644 --- a/src/server.rs +++ b/src/server.rs @@ -44,6 +44,8 @@ mod clipboard_service; pub use clipboard_service::is_clipboard_service_ok; #[cfg(target_os = "linux")] pub(crate) mod wayland; +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(crate) mod drm_capturer; #[cfg(target_os = "linux")] pub mod uinput; #[cfg(target_os = "linux")] @@ -599,6 +601,25 @@ pub async fn start_server(is_server: bool, no_server: bool) { std::process::exit(-1); } }); + // Warm the DRM availability cache before any client connects, so the first connection does + // not race a cold `_drm` probe and ship an empty display list ("No displays" + retry). + // X11 is skipped -- probing there makes the root service open DRM readers for a path this + // session can never take -- but that decision belongs to `warm_availability`, which already + // makes it, and NOT to this call site. Deciding it here is the same one-shot-at-startup + // mistake the pre-warm had: `is_x11()` answers "x11" whenever loginctl cannot yet name the + // seat0 session, which during a boot is exactly when this runs, and nothing revisits it -- + // so a Wayland host that came up slowly skipped the warm for the life of the process and + // got back the cold-probe "No displays" symptom the warm exists to remove. + #[cfg(all(target_os = "linux", feature = "drm"))] + if let Err(err) = std::thread::Builder::new() + .name("drm-warm".into()) + .spawn(drm_capturer::warm_availability) + { + // Same reason as the root service's startup threads: `thread::spawn` panics on EAGAIN + // and that would abort `start_server`. Skipping the warm costs the first session the + // cold probe, which is what happened before the warm existed. + log::warn!("drm: could not spawn the availability warm ({err}); skipping it"); + } input_service::fix_key_down_timeout_loop(); #[cfg(target_os = "linux")] if input_service::wayland_use_uinput() { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 8531076a9..3647d7ee6 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -65,6 +65,13 @@ pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) { WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); } +// The uinput ABS range currently programmed into the device, for the DRM path's "reapply only when +// it changed" check. The PipeWire path compares it inline in refresh_wayland_uinput_rect_if_changed. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub(super) fn wayland_uinput_rect() -> Option<(i32, i32, i32, i32)> { + WAYLAND_UINPUT_RECT.lock().unwrap().rect +} + #[cfg(target_os = "linux")] pub(super) fn set_wayland_layout_baseline(baseline: Vec) { WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed); @@ -328,6 +335,16 @@ fn check_get_displays_changed_msg() -> Option { #[cfg(target_os = "linux")] { if !is_x11() { + // On the DRM/KMS capture path the PipeWire enumeration (which is what feeds + // `SYNC_DISPLAYS` via `check_update_displays`) is bypassed, so populate the sync list + // from the DRM display list here. Without this the display service broadcasts an empty + // list that overwrites the login peer-info displays and the client shows "No displays". + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + if let Some(displays) = super::drm_capturer::get_display_infos() { + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + } + } return get_displays_msg(); } } @@ -434,6 +451,33 @@ pub(super) fn get_display_info(idx: usize) -> Option { SYNC_DISPLAYS.lock().unwrap().displays.get(idx).cloned() } +// True when at least one advertised (synced) display is NOT served by the DRM/KMS capture path, +// i.e. a mixed DRM + PipeWire session. The cursor service (platform::linux::get_cursor / +// get_cursor_data) uses this to decide whether a hidden DRM hardware-cursor sentinel is +// authoritative: in a pure-DRM session it is (the pointer is genuinely off every captured CRTC), +// but in a mixed session the sentinel only means the pointer moved onto a PipeWire-served display, +// whose cursor must come from the normal path instead of being hidden everywhere. +// +// When DRM capture is active the advertised list is enumerated from the DRM display list, so a DRM +// list shorter than the synced list means at least one advertised display is served by PipeWire. +#[cfg(all(target_os = "linux", feature = "drm"))] +pub fn has_non_drm_backed_display() -> bool { + match super::drm_capturer::display_count_and_any_demoted() { + // A display served by PipeWire is either ABSENT from the DRM list (a shorter count, e.g. a + // pure-portal display) or PRESENT-BUT-DEMOTED (kept in place at the same index and marked + // offline so the index space stays aligned -- see get_display_infos). The count check alone + // misses the demotion case (same count), so a demoted display is treated as non-DRM-backed + // too. This is what gates the hidden-cursor sentinel: it stays authoritative only in a + // pure-DRM session. The scalar accessor is deliberate: this is polled every cursor tick + // while the sentinel is active, and cloning + geometry-augmenting the whole list per tick + // (what get_display_infos does) answered the same two facts. + Some((count, any_demoted)) => { + count < SYNC_DISPLAYS.lock().unwrap().displays.len() || any_demoted + } + None => false, + } +} + // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { diff --git a/src/server/drm_capturer.rs b/src/server/drm_capturer.rs new file mode 100644 index 000000000..d447715df --- /dev/null +++ b/src/server/drm_capturer.rs @@ -0,0 +1,1670 @@ +// Unprivileged consumer of the root `--service`'s DRM/KMS capture stream: the service does the +// privileged export (open + grab the scanout dma-buf fd), the EGL detile / RGBA convert runs here. + +use crate::ipc::{connect_drm, Data, DrmDisplayInfo}; +use hbb_common::{anyhow::anyhow, bail, log, message_proto::DisplayInfo, tokio, ResultType}; +use scrap::drm_render::RenderConverter; +use scrap::drmtap_dl::drmtap_dmabuf_desc; +use scrap::{Frame, Pixfmt, PixelBuffer, TraitCapturer}; +use std::collections::BTreeMap; +use std::io; +use std::os::fd::{AsRawFd, RawFd}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +const HANDSHAKE_TIMEOUT_MS: u64 = 3000; +const DRM_CONNECT_TIMEOUT_MS: u64 = 1000; +/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*). +const DISPLAY_LIST_TIMEOUT_MS: u64 = HANDSHAKE_TIMEOUT_MS + 4000; +/// Covers the connect timeout plus `recv_msg_timeout2` applying DISPLAY_LIST_TIMEOUT_MS TWICE +/// (first byte, then body). The render-node open and the DrmStart send can still overrun it. +const HANDSHAKE_WAIT_MS: u64 = DRM_CONNECT_TIMEOUT_MS + DISPLAY_LIST_TIMEOUT_MS * 2 + 500; +/// Only the header read rechecks `stop`, so bound the body read here rather than relying on + /// `next_raw_into`'s own cap. +const BODY_READ_TIMEOUT: Duration = Duration::from_secs(5); + +struct FrameSlot { + // Row stride is `pixels.len() / height`, possibly padded; the format is per frame. + latest: Option<(usize, usize, Pixfmt, Vec)>, + // TWO slots: two buffers can be idle at once -- the receive path takes one and publishes in two + // SEPARATE acquisitions, so the encoder can hand its borrow back in between. + free: [Option>; 2], + ended: Option, +} + +impl FrameSlot { + fn publish(&mut self, w: usize, h: usize, fmt: Pixfmt, buf: Vec) { + if let Some((.., old)) = self.latest.take() { + self.recycle(old); + } + self.latest = Some((w, h, fmt, buf)); + } + + fn recycle(&mut self, buf: Vec) { + if let Some(slot) = self.free.iter_mut().find(|s| s.is_none()) { + *slot = Some(buf); + } + } + + fn take_free(&mut self) -> Option> { + self.free.iter_mut().find_map(|s| s.take()) + } +} + +struct Shared { + slot: Mutex, + cv: Condvar, +} + +pub struct IpcDrmCapturer { + shared: Arc, + stop: Arc, + display: i32, + connector: Option, + // What the encoder was sized from: CapturerInfo{width,height} is read once, at build time. + session_size: Option<(usize, usize)>, + cur: Vec, + cur_w: usize, + cur_h: usize, + cur_fmt: Pixfmt, + got_frame: bool, +} + +/// A list index is NOT an identity: `drm_enumerate_all_displays` concatenates per-card lists. +fn connector_key(d: &DrmDisplayInfo) -> String { + format!("{}:{}", d.device, d.name) +} + +/// Takes DRM_STATE: never call it while holding one of the per-display maps below. +fn display_info_of(display: i32) -> Option { + match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.get(display.max(0) as usize).cloned(), + _ => None, + } +} + +/// A delivered frame resets the streak verdicts (`zero_frame_streak`, `demotes`, `since`) and + /// nothing else. +#[derive(Clone, Copy)] +struct DisplayHealth { + zero_frame_streak: u32, + since: Instant, + demotes: u32, + last_build: Option, + rapid_builds: u32, + /// The dma-buf convert failed for this display. The COMMON cause is multi-GPU: our render node + /// is not the GPU that exported the scanout. Follows the monitor for the process run. + prefer_cpu: bool, +} + +impl DisplayHealth { + fn new() -> Self { + Self { + zero_frame_streak: 0, + since: Instant::now(), + demotes: 0, + last_build: None, + rapid_builds: 0, + prefer_cpu: false, + } + } + + fn demoted(&self) -> bool { + self.zero_frame_streak >= DRM_GRAB_MAX_FAILURES + && self.since.elapsed() < demote_cooldown(self.demotes) + } +} + +static DRM_DISPLAY_HEALTH: Mutex> = Mutex::new(BTreeMap::new()); +const DRM_GRAB_MAX_FAILURES: u32 = 4; +const DEMOTE_COOLDOWN: Duration = Duration::from_secs(30); +const DEMOTE_BACKOFF_MAX_SHIFT: u32 = 4; +const RAPID_REBUILD_WINDOW: Duration = Duration::from_secs(3); +const RAPID_REBUILD_MAX: u32 = 6; + +/// Doubling per demotion up to `DEMOTE_BACKOFF_MAX_SHIFT`; a delivered frame zeroes the demote +/// count (see `frame()`), not decayed by time. +fn demote_cooldown(demotes: u32) -> Duration { + DEMOTE_COOLDOWN * (1u32 << demotes.saturating_sub(1).min(DEMOTE_BACKOFF_MAX_SHIFT)) +} + +#[derive(Debug, PartialEq, Eq)] +enum RefreshOutcome { + Publish, + Unavailable, + Restamp, + /// The evidence is about the PRODUCER, not the hardware: give the verdict up to `Unknown`. + GiveUp, +} + +/// `failures` counts consecutive failures INCLUDING this one, so it is 1 on the first. +fn refresh_outcome(probe: Option, failures: u32) -> RefreshOutcome { + match probe { + Some(0) => RefreshOutcome::Unavailable, + Some(_) => RefreshOutcome::Publish, + None if failures >= DRM_REFRESH_MAX_FAILURES => RefreshOutcome::GiveUp, + None => RefreshOutcome::Restamp, + } +} + +fn drm_prefer_cpu(key: &Option) -> bool { + key.as_ref().is_some_and(|k| { + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(k) + .is_some_and(|h| h.prefer_cpu) + }) +} + +fn drm_set_prefer_cpu(key: &Option) { + if let Some(k) = key { + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .entry(k.clone()) + .or_insert_with(DisplayHealth::new) + .prefer_cpu = true; + } +} + +fn render_node_count() -> usize { + std::fs::read_dir("/dev/dri").map_or(0, |entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix("renderD")) + .and_then(|minor| minor.parse::().ok()) + .is_some() + }) + .count() + }) +} + +static UINPUT_REFRESH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +impl IpcDrmCapturer { + /// The service resolves indices against ITS OWN enumeration, so the receive thread re-resolves + /// `expected` by connector identity and returns the index geometry must be read at. + pub fn new( + display: i32, + expected: Option, + ) -> ResultType<(IpcDrmCapturer, Vec, usize)> { + let shared = Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }); + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel::, usize)>>(); + { + let shared = shared.clone(); + let stop = stop.clone(); + std::thread::Builder::new() + .name("drm-recv".into()) + .spawn(move || recv_thread(display, expected, shared, stop, tx)) + .map_err(|err| anyhow!("could not spawn the drm receive thread: {err}"))?; + } + let (displays, wire_idx) = match rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) { + Ok(res) => res?, + Err(_) => { + // A handshake completing later would stream unowned: Drop never runs here. + stop.store(true, Ordering::SeqCst); + bail!("drm capture handshake timed out"); + } + }; + Ok(( + IpcDrmCapturer { + shared, + stop, + display, + connector: displays.get(wire_idx).map(connector_key), + session_size: displays + .get(wire_idx) + .map(|d| (d.width as usize, d.height as usize)), + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + }, + displays, + wire_idx, + )) + } + + /// Without an identity, skip rather than record under "", which get_capturer_info reads back + /// as the same key: one unidentifiable display would demote the next. + fn note_session_without_frame(&self) { + let Some(key) = self.connector.clone() else { + log::debug!( + "drm: display {} produced no frame but has no connector identity; \ + not counting it against any display", + self.display + ); + return; + }; + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.zero_frame_streak += 1; + h.since = Instant::now(); + if h.zero_frame_streak == DRM_GRAB_MAX_FAILURES { + h.demotes += 1; + log::warn!( + "drm: display {} produced no frame in {} sessions; using PipeWire for it, \ + retrying DRM in {:?} (demotion {})", + self.display, + h.zero_frame_streak, + demote_cooldown(h.demotes), + h.demotes + ); + } + } +} + +impl Drop for IpcDrmCapturer { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + } +} + +impl TraitCapturer for IpcDrmCapturer { + fn frame<'a>(&'a mut self, timeout: Duration) -> io::Result> { + let deadline = Instant::now() + timeout; + { + let mut slot = self.shared.slot.lock().unwrap(); + loop { + if slot.latest.is_some() || slot.ended.is_some() { + break; + } + let now = Instant::now(); + if now >= deadline { + return Err(io::ErrorKind::WouldBlock.into()); + } + let (guard, _timed_out) = + self.shared.cv.wait_timeout(slot, deadline - now).unwrap(); + slot = guard; + } + if let Some((w, h, fmt, buf)) = slot.latest.take() { + drop(slot); + // convert_to_yuv only refuses a source LARGER than its destination, so a smaller + // frame leaves stale edges on screen. On the FIRST frame nothing changed: the list + // carries the CRTC mode, a frame the scanout fb, different when a CRTC scales. + if self.session_size.is_some_and(|(sw, sh)| (w, h) != (sw, sh)) { + self.shared.slot.lock().unwrap().recycle(buf); + if !self.got_frame { + self.note_session_without_frame(); + } + let (sw, sh) = self.session_size.unwrap_or_default(); + let what = if self.got_frame { + "changed geometry mid-session" + } else { + "never matched its advertised geometry" + }; + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "drm: display {} {what} ({sw}x{sh} -> {w}x{h}); rebuilding", + self.display + ), + )); + } + let previous = std::mem::replace(&mut self.cur, buf); + self.shared.slot.lock().unwrap().recycle(previous); + self.cur_w = w; + self.cur_h = h; + self.cur_fmt = fmt; + if !self.got_frame { + // Clear ONLY the streak: `rapid_builds` is for a display that delivers a first + // frame then fails, and `prefer_cpu` is written on the recv thread. + self.got_frame = true; + if let Some(key) = &self.connector { + if let Some(h) = DRM_DISPLAY_HEALTH.lock().unwrap().get_mut(key) { + h.zero_frame_streak = 0; + h.demotes = 0; + h.since = Instant::now(); + } + } + } + } else { + let err = slot + .ended + .clone() + .unwrap_or_else(|| "drm stream ended".to_owned()); + if !self.got_frame { + self.note_session_without_frame(); + } + return Err(io::Error::new(io::ErrorKind::Other, err)); + } + } + Ok(Frame::PixelBuffer(PixelBuffer::new( + &self.cur, + self.cur_fmt, + self.cur_w, + self.cur_h, + ))) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn recv_thread( + display: i32, + expected: Option, + shared: Arc, + stop: Arc, + tx: std::sync::mpsc::Sender, usize)>>, +) { + let cursor_epoch = next_cursor_epoch(); + let mut conn = match connect_drm(DRM_CONNECT_TIMEOUT_MS).await { + Ok(c) => c, + Err(err) => { + let _ = tx.send(Err(err)); + return; + } + }; + let displays = match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await { + Some(Ok((Data::DrmDisplayList(v), _fd))) => v, + Some(Ok((other, _fd))) => { + let _ = tx.send(Err(anyhow!("expected DrmDisplayList, got {:?}", other))); + return; + } + Some(Err(err)) => { + let _ = tx.send(Err(err)); + return; + } + None => { + let _ = tx.send(Err(anyhow!("timed out waiting for DrmDisplayList"))); + return; + } + }; + // Our monitor's index IN THIS CONNECTION'S LIST; `display` indexes the CLIENT's. Measured on a + // T2: a woken 2880x1800 panel re-enters ahead of the Touch Bar, flipping index 0. + let wire_idx = match &expected { + Some(e) => { + match displays + .iter() + .position(|d| d.device == e.device && d.name == e.name) + { + Some(i) => i, + None => { + let _ = tx.send(Err(anyhow!( + "display {display} ({}) is no longer in the service's list; \ + the video service will rebuild against the fresh topology", + e.name + ))); + return; + } + } + } + None => { + let _ = tx.send(Err(anyhow!( + "display {display} is not in the advertised list; not guessing a monitor for it" + ))); + return; + } + }; + // (device, crtc_id) survives a topology change; list indices do not. + let bound_to = displays + .get(wire_idx) + .map(|d| (d.device.clone(), d.crtc_id)); + let our_key = displays.get(wire_idx).map(connector_key); + let render_node = displays + .get(wire_idx) + .or_else(|| displays.first()) + .map(|d| d.render_node.clone()) + .unwrap_or_default(); + // An unnamed exporter on a multi-render-node host fails SILENTLY: on a Jetson + // (scanout nvidia-drm, first render node tegra) the wrong device's import SUCCEEDS and corrupts + // the pixels, so there is no convert error for prefer_cpu to learn from. + let ambiguous_gpu = render_node.is_empty() && render_node_count() > 1; + let force_cpu = drm_prefer_cpu(&our_key) || ambiguous_gpu; + let mut converter = if force_cpu { + None + } else { + RenderConverter::open_render(Some(render_node.as_str())) + }; + let need_cpu = converter.is_none(); + if need_cpu { + log::info!( + "drm: requesting the CPU-converted frame path for display {display} ({})", + if ambiguous_gpu { + "the service did not name the exporting GPU and this host has several render nodes; \ + auto-selecting one can import the scanout on the wrong device and silently corrupt it" + } else if force_cpu { + "a prior consumer convert failed, e.g. multi-GPU render-node mismatch" + } else { + "no render-node convert context: libdrmtap did not load here, or \ + drmtap_open_render found no usable /dev/dri/renderD*" + } + ); + } + if let Err(err) = conn + .send_msg( + &Data::DrmStart { + display: wire_idx as i32, + need_cpu, + }, + None, + ) + .await + { + let _ = tx.send(Err(err)); + return; + } + let _ = tx.send(Ok((displays, wire_idx))); + + let end_reason = loop { + if stop.load(Ordering::SeqCst) { + break "stopped".to_owned(); + } + let (msg, recv_fd) = match conn.recv_msg_timeout2(200).await { + None => continue, // timeout: re-check stop at the loop top + Some(Ok(pair)) => pair, + Some(Err(err)) => break format!("recv: {err}"), + }; + match msg { + Data::DrmFrameDmabuf(desc) => { + let conv = match converter.as_mut() { + Some(c) => c, + None => break "no DRM render node; cannot convert dma-buf frame".to_owned(), + }; + // Valid in THIS process; -1 is an import-once cache hit on `fb_id`. + let received_fd: RawFd = if desc.has_fd { + match recv_fd.as_ref() { + Some(f) => f.as_raw_fd(), + None => { + break "dma-buf frame set has_fd but carried no SCM_RIGHTS fd".to_owned() + } + } + } else { + -1 + }; + let mut ddesc = drmtap_dmabuf_desc { + dma_buf_fd: -1, + width: desc.width, + height: desc.height, + format: desc.format, + modifier: desc.modifier, + fb_id: desc.fb_id, + // RAW: `drm_render::convert` REJECTS an out-of-range count rather than + // clamping, so the count the C reads is the one that was validated. + num_planes: desc.num_planes, + offsets: desc.offsets, + pitches: desc.pitches, + hdr_eotf: desc.hdr_eotf, + hdr_max_nits: desc.hdr_max_nits, + }; + match conv.convert(&mut ddesc, received_fd) { + Ok((data, w, h, fmt)) => { + // Borrowed from the render context, valid only until the next convert. + // Copy into a recycled buffer, and OUTSIDE the slot lock, so a + // multi-megabyte memcpy never holds the encoder off the slot. + let mut buf = shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.extend_from_slice(data); + let mut slot = shared.slot.lock().unwrap(); + slot.publish(w as usize, h as usize, fmt, buf); + shared.cv.notify_one(); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) => { + drm_set_prefer_cpu(&our_key); + break format!("convert: {err}"); + } + } + // `recv_fd` closes at the end of this iteration, AFTER convert imported it. + // Ack so the producer RELEASES ONE SEND CREDIT and forwards the next; this bounds + // the socket to a couple of in-flight frames instead of a stale backlog. + if let Err(err) = conn.send_frame_ack().await { + break format!("frame ack: {err}"); + } + } + Data::DrmFrame { width, height } => { + // `frame()` hands this to PixelBuffer::new, which derives the stride as + // `data.len() / height`: height==0 would DIVIDE BY ZERO. + if width == 0 || height == 0 { + break format!("cpu frame: degenerate geometry {width}x{height}"); + } + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + let mut buf = shared.slot.lock().unwrap().take_free().unwrap_or_default(); + match tokio::time::timeout(BODY_READ_TIMEOUT, conn.next_raw_into(&mut buf)).await { + Err(_) => break "cpu frame body read timed out".to_owned(), + Ok(Ok(())) => { + if buf.len() < need { + break format!( + "cpu frame: body {} bytes < {need} for {width}x{height}", + buf.len() + ); + } + let mut slot = shared.slot.lock().unwrap(); + slot.publish(width as usize, height as usize, Pixfmt::BGRA, buf); + shared.cv.notify_one(); + } + Ok(Err(err)) => break format!("frame body: {err}"), + } + // Ack this CPU frame too (flow control; see the dma-buf arm above). + if let Err(err) = conn.send_frame_ack().await { + break format!("frame ack: {err}"); + } + } + Data::DrmCursor { + id, + width, + height, + hotx, + hoty, + } => { + // get_cursor_data() hands `colors` straight to the client, which renders + // width*height*4 RGBA bytes: a short body would make it READ PAST THE BUFFER. A + // hidden-cursor sentinel arrives as 1x1 with a 4-byte body, so `need` is 4 and the + // check is live. + let need = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(4); + let mut raw = Vec::new(); + match tokio::time::timeout(BODY_READ_TIMEOUT, conn.next_raw_into(&mut raw)).await { + Err(_) => break "cursor body read timed out".to_owned(), + Ok(Ok(())) => { + if raw.len() < need { + break format!( + "cursor body {} bytes < {need} for {width}x{height}", + raw.len() + ); + } + set_drm_cursor( + display, + cursor_epoch, + DrmCursorData { + id, + width: width as i32, + height: height as i32, + hotx, + hoty, + colors: raw, + }, + ); + } + Ok(Err(err)) => break format!("cursor body: {err}"), + } + } + Data::DrmDisplaysChanged(list) => { + // `display` (the CLIENT's index) and NOT `wire_idx`, deliberately. `bound_to` is an + // identity `(device, crtc_id)`, not a position, so this asks "does that slot still + // name MY monitor"; and the swap below installs this list as DRM_STATE, which is the + // client-space list display_service re-advertises and input is mapped through. + // Probing `wire_idx` stays quiet in exactly the case this guard exists for: a stream + // whose wire_idx differs from display keeps running while the client's index comes to + // mean another monitor. Checked BEFORE the swap, against the topology this stream + // started on. + let now_at_our_index = list + .get(display.max(0) as usize) + .map(|d| (d.device.clone(), d.crtc_id)); + if bound_to.is_some() && now_at_our_index != bound_to { + swap_available_displays(list); + scrap::wayland::display::clear_wayland_displays_cache(); + break match (&bound_to, &now_at_our_index) { + (Some((_, was)), Some((_, now))) => format!( + "hotplug renumbered display {display}: it was crtc {was}, now crtc {now}" + ), + _ => format!("hotplug removed display {display} from the list"), + }; + } + swap_available_displays(list); + scrap::wayland::display::clear_wayland_displays_cache(); + UINPUT_REFRESH_GEN.fetch_add(1, Ordering::AcqRel); + if !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel) { + // Taken BEFORE the spawn and moved in: `Builder::spawn` can FAIL with EAGAIN after + // the swap, so a guard built inside the closure would never exist and the flag + // would stay set for the PROCESS LIFETIME. + let mut busy = UinputRefreshGuard(true); + let spawned = std::thread::Builder::new() + .name("drm-uinput-refresh".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(err) => { + log::warn!( + "drm: uinput refresh worker could not build a runtime: {err}" + ); + return; // the guard hands the slot back + } + }; + let mut served = 0u64; + loop { + let g = UINPUT_REFRESH_GEN.load(Ordering::Acquire); + if g != served { + served = g; + rt.block_on(super::wayland::update_uinput_resolution()); + continue; + } + busy.release(); + if UINPUT_REFRESH_GEN.load(Ordering::Acquire) == served { + break; + } + if !busy.retake() { + break; // another handler already started a fresh worker + } + } + }); + if let Err(err) = spawned { + log::error!("drm: could not spawn the uinput refresh worker: {err}"); + } + } + } + _ => {} // ignore any unexpected control message + } + }; + log::info!("drm capture stream ended: {end_reason}"); + // Drop the render context on THIS thread: its EGL state + cached imports are thread-local and + // a cross-thread close strands them. Never in `Drop`, which runs on the encoder thread. + drop(converter); + remove_drm_cursor(display, cursor_epoch); + let mut slot = shared.slot.lock().unwrap(); + slot.ended = Some(format!("drm stream ended ({end_reason})")); + shared.cv.notify_one(); +} + +// Keyed by display index: the cursor lives on whichever CRTC the pointer is over and every other +// stream reports a hidden sentinel, which under a single global would clobber it. +#[derive(Clone)] +pub struct DrmCursorData { + pub id: u64, + pub width: i32, + pub height: i32, + pub hotx: i32, + pub hoty: i32, + pub colors: Vec, +} + +static DRM_CURSOR: Mutex> = Mutex::new(BTreeMap::new()); +// Monotonic per-stream tag: a rebuilt stream reuses the display index, so a torn-down stream drops +// its entry ONLY if the epoch still matches. +static DRM_CURSOR_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_cursor_epoch() -> u64 { + DRM_CURSOR_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +// Compare-and-set: a still-draining predecessor stream (older epoch) must not overwrite the entry a +// replacement stream (newer epoch) already published. Only accept a write whose epoch is at least +// the stored one. +fn set_drm_cursor(display: i32, epoch: u64, c: DrmCursorData) { + let mut map = DRM_CURSOR.lock().unwrap(); + match map.get(&display) { + Some((stored, _)) if *stored > epoch => {} + _ => { + map.insert(display, (epoch, c)); + } + } +} + +fn remove_drm_cursor(display: i32, epoch: u64) { + let mut map = DRM_CURSOR.lock().unwrap(); + if map.get(&display).map(|(e, _)| *e) == Some(epoch) { + map.remove(&display); + } +} + +fn with_drm_cursor(f: impl Fn(&DrmCursorData) -> T) -> Option { + let map = DRM_CURSOR.lock().unwrap(); + map.values() + .map(|(_, c)| c) + .find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID) + .or_else(|| map.values().map(|(_, c)| c).next()) + .map(f) +} + +pub fn drm_cursor_id() -> Option { + with_drm_cursor(|c| c.id) +} + +/// Snapshot of the DRM hardware cursor, or None. The pixels are premultiplied ARGB and are passed +/// through as-is, like the XFixes path, so the client sees one cursor format from either backend. +pub fn drm_cursor() -> Option { + with_drm_cursor(|c| c.clone()) +} + +enum ProbeState { + Unknown, + Unavailable(Instant), + Available(Instant, Vec), +} + +static DRM_STATE: Mutex = Mutex::new(ProbeState::Unknown); +const NEGATIVE_TTL: Duration = Duration::from_secs(30); +const POSITIVE_TTL: Duration = Duration::from_secs(15); + +/// Runs on a throwaway thread: a nested `#[tokio::main]` panics if called from inside a runtime. +fn query_displays() -> ResultType> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("drm-query".into()) + .spawn(move || { + let _ = tx.send(query_displays_async()); + }) + .map_err(|err| anyhow!("could not spawn the drm display query thread: {err}"))?; + rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) + .map_err(|_| anyhow!("drm display query timed out"))? +} + +#[tokio::main(flavor = "current_thread")] +async fn query_displays_async() -> ResultType> { + query_displays_inner().await +} + +async fn query_displays_inner() -> ResultType> { + let mut conn = connect_drm(DRM_CONNECT_TIMEOUT_MS).await?; + match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await { + Some(Ok((Data::DrmDisplayList(v), _fd))) => Ok(v), + Some(Ok((other, _fd))) => Err(anyhow!("expected DrmDisplayList, got {:?}", other)), + Some(Err(err)) => Err(err), + None => Err(anyhow!("timed out waiting for DrmDisplayList")), + } +} + +static DRM_PROBE_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_PROBE_MAX_FAILURES: u32 = 5; +static DRM_REFRESH_FAILURES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +const DRM_REFRESH_MAX_FAILURES: u32 = 3; +// Single-flight, so is_available() never calls query_displays() (~4s of IPC) holding DRM_STATE. +static DRM_PROBE_IN_FLIGHT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Advanced by every publish, so a slow UNLOCKED probe can tell a newer verdict landed meanwhile. +static DRM_STATE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// EVERY verdict change to DRM_STATE goes through here so the generation stays truthful; the TTL + /// restamp in `refresh_available_async` is the one direct write. +#[inline] +fn publish_probe_state(st: &mut ProbeState, next: ProbeState) { + *st = next; + DRM_STATE_GEN.fetch_add(1, Ordering::Release); +} + +/// Releases DRM_PROBE_IN_FLIGHT on EVERY exit; a leaked release wedges all future probes. +struct ProbeInFlightGuard; +impl Drop for ProbeInFlightGuard { + fn drop(&mut self) { + DRM_PROBE_IN_FLIGHT.store(false, Ordering::Release); + } +} + +/// Ownership of `UINPUT_REFRESH_BUSY`, released on every exit. It is handed back and re-taken +/// mid-loop, so releasing on drop unconditionally would clear a flag a REPLACEMENT worker owns. +struct UinputRefreshGuard(bool); +impl UinputRefreshGuard { + fn release(&mut self) { + if self.0 { + self.0 = false; + UINPUT_REFRESH_BUSY.store(false, Ordering::Release); + } + } + fn retake(&mut self) -> bool { + self.0 = !UINPUT_REFRESH_BUSY.swap(true, Ordering::AcqRel); + self.0 + } +} +impl Drop for UinputRefreshGuard { + fn drop(&mut self) { + self.release(); + } +} + +/// Never probes, never blocks: the form the ROUTING gates must use. Seconds of IPC inside +/// `wayland::clear()`, `is_inited()` or the display enumeration trips "deadline has elapsed". +pub(super) fn is_available_cached() -> bool { + matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) +} + +/// MAY BLOCK for seconds: never a routing gate. +pub(super) fn is_available() -> bool { + let verdict = { + let mut st = DRM_STATE.lock().unwrap(); + if let ProbeState::Unavailable(since) = &*st { + if since.elapsed() >= NEGATIVE_TTL { + publish_probe_state(&mut st, ProbeState::Unknown); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + } + } + match &*st { + ProbeState::Available(since, _) => Some((true, since.elapsed() >= POSITIVE_TTL)), + ProbeState::Unavailable(_) => Some((false, false)), + ProbeState::Unknown => None, // fall through and probe with the lock released + } + }; + if let Some((available, stale)) = verdict { + if stale { + refresh_available_async(); + } + return available; + } + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)); + } + let _in_flight = ProbeInFlightGuard; + let t = Instant::now(); + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + let available = match result { + Ok(list) if !list.is_empty() => { + log::debug!( + "drm: availability probe -> available ({} displays) in {:?}", + list.len(), + t.elapsed() + ); + DRM_PROBE_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + true + } + Ok(_) => { + log::info!("drm: availability probe -> no displays in {:?}", t.elapsed()); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + false + } + Err(err) => { + let n = DRM_PROBE_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if n >= DRM_PROBE_MAX_FAILURES { + log::info!("drm: availability probe failed {n}x ({err}); disabling DRM"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } else { + log::info!( + "drm: availability probe failed ({err}), attempt {n}/{DRM_PROBE_MAX_FAILURES}; will retry" + ); + } + false + } + }; + drop(st); + available +} + +fn refresh_available_async() { + if DRM_PROBE_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let in_flight = ProbeInFlightGuard; + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let spawned = std::thread::Builder::new() + .name("drm-avail-refresh".into()) + .spawn(move || { + let _in_flight = in_flight; + let result = query_displays(); + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + return; + } + let failures = match &result { + Ok(_) => { + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + 0 + } + Err(_) => DRM_REFRESH_FAILURES.fetch_add(1, Ordering::Relaxed) + 1, + }; + match refresh_outcome(result.as_ref().ok().map(|l| l.len()), failures) { + RefreshOutcome::Publish => { + let fresh = result.unwrap_or_default(); + let changed = match &*st { + ProbeState::Available(_, old) => *old != fresh, + _ => true, + }; + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), fresh)); + if changed { + drop(st); + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + RefreshOutcome::Unavailable => { + log::info!("drm: refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } + // Only the TTL stamp moves, so this does NOT go through publish_probe_state. + RefreshOutcome::Restamp => { + if let ProbeState::Available(since, _) = &mut *st { + *since = Instant::now(); + } + } + RefreshOutcome::GiveUp => { + log::info!( + "drm: availability refresh failed {failures}x ({:?}); the producer looks \ + gone, dropping the cached verdict so the next enumeration re-probes", + result.as_ref().err() + ); + DRM_REFRESH_FAILURES.store(0, Ordering::Relaxed); + publish_probe_state(&mut st, ProbeState::Unknown); + } + } + }); + // Nothing to release: the guard moved into the closure and drops with it. Clearing the flag + // explicitly would let TWO PROBES RUN AT ONCE, since another refresh may already hold it. + if let Err(err) = spawned { + log::warn!( + "drm: could not spawn the availability refresh thread: {err}; the cached verdict \ + stays stale until the next probe" + ); + } +} + +pub(super) fn warm_availability() { + // The gate is INSIDE the loop because `get_display_server()` answers "x11" whenever loginctl + // cannot yet name the seat0 session. `scrap::is_x11()` is the UNMEMOISED form. + for _ in 0..10 { + if scrap::is_x11() { + std::thread::sleep(Duration::from_millis(300)); + continue; + } + if matches!(&*DRM_STATE.lock().unwrap(), ProbeState::Available(..)) { + return; + } + match query_displays() { + Ok(list) if !list.is_empty() => { + log::info!("drm: consumer cache warmed ({} displays) at startup", list.len()); + publish_probe_state(&mut DRM_STATE.lock().unwrap(), ProbeState::Available(Instant::now(), list)); + return; + } + _ => std::thread::sleep(Duration::from_millis(300)), + } + } + log::info!("drm: consumer cache warm found no producer at startup (will probe lazily)"); +} + +/// The service holds its answer until the topology settles. Replaces only an `Available` verdict. +pub(super) async fn refresh_displays_for_login() { + let sampled_gen = { + let st = DRM_STATE.lock().unwrap(); + if !matches!(&*st, ProbeState::Available(..)) { + return; + } + DRM_STATE_GEN.load(Ordering::Acquire) + }; + let t = Instant::now(); + match query_displays_inner().await { + Ok(list) if !list.is_empty() => { + let changed = { + let mut st = DRM_STATE.lock().unwrap(); + if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen { + log::debug!( + "drm: login display refresh superseded while probing; keeping the newer list" + ); + return; + } + match &*st { + ProbeState::Available(_, old) => { + let changed = *old != list; + log::debug!( + "drm: login display refresh -> {} display(s) in {:?}{}", + list.len(), + t.elapsed(), + if changed { " (list changed)" } else { "" } + ); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + changed + } + _ => return, + } + }; + if changed { + scrap::wayland::display::clear_wayland_displays_cache(); + } + } + Ok(_) => log::debug!( + "drm: login display refresh found no displays in {:?}; keeping the cached list", + t.elapsed() + ), + Err(err) => log::debug!( + "drm: login display refresh failed in {:?} ({err}); keeping the cached list", + t.elapsed() + ), + } +} + +/// Mirrors get_display_infos: only a MULTI-display host advertises a demoted display. +pub(super) fn display_count_and_any_demoted() -> Option<(usize, bool)> { + // Snapshot the identity keys under DRM_STATE, then consult health with DRM_STATE RELEASED -- + // same order as get_display_infos: never hold DRM_STATE while taking a per-display map. + let (len, keys): (usize, Vec) = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => ( + list.len(), + if list.len() > 1 { + list.iter().map(connector_key).collect() + } else { + Vec::new() + }, + ), + _ => return None, + }; + let any_demoted = if len > 1 { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + keys.iter() + .any(|k| health.get(k).is_some_and(|h| h.demoted())) + } else { + false + }; + Some((len, any_demoted)) +} + +/// Releases DRM_STATE before taking the health map: never hold it while taking a per-display map. +pub(super) fn get_display_infos() -> Option> { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return None, + }; + let multi = list.len() > 1; + let mut infos = augment_with_wayland_geometry(&list); + // The portal exposes one whole-desktop stream, so a demoted display on a multi-monitor host + // has nothing geometry-consistent to fall back to: OFFLINE but KEEPING its list position, so + // the index space stays aligned with get_capturer_info(). A single-display host stays online. + if multi { + let health = DRM_DISPLAY_HEALTH.lock().unwrap(); + for (idx, info) in infos.iter_mut().enumerate() { + let key = match list.get(idx) { + Some(d) => connector_key(d), + None => continue, + }; + if health.get(&key).is_some_and(|h| h.demoted()) { + info.online = false; + } + } + } + Some(infos) +} + +/// Index of the compositor's PRIMARY output; 0 when unknown. Asking `assign_wayland_outputs` makes +/// the advertised primary and geometry agree, but not below two connectors or two outputs, where +/// `augment_with_wayland_geometry` declines to run the assignment. +pub(super) fn get_primary_index() -> usize { + let list = match &*DRM_STATE.lock().unwrap() { + ProbeState::Available(_, list) => list.clone(), + _ => return 0, + }; + let wl = scrap::wayland::display::get_displays(); + if wl.displays.is_empty() { + return 0; + } + assign_wayland_outputs(&list, &wl.displays) + .iter() + .position(|assigned| *assigned == Some(wl.primary)) + .unwrap_or(0) +} + +/// DRM reports every monitor at physical size and origin (0,0), stacking a multi-monitor client. +fn augment_with_wayland_geometry(drm: &[DrmDisplayInfo]) -> Vec { + let wl = scrap::wayland::display::get_displays(); + let mut infos: Vec = drm.iter().map(display_info_from_drm).collect(); + if drm.len() < 2 || wl.displays.len() < 2 { + return infos; + } + let matched = assign_wayland_outputs(drm, &wl.displays); + for (i, info) in infos.iter_mut().enumerate() { + let Some(w) = matched[i].map(|j| &wl.displays[j]) else { + continue; + }; + info.x = w.x; + info.y = w.y; + if let Some((lw, lh)) = w.logical_size { + if lw > 0 && lh > 0 { + info.scale = drm[i].width as f64 / lw as f64; + info.original_resolution = super::display_service::get_original_resolution( + &drm[i].name, + lw as usize, + lh as usize, + ); + } + } + } + infos +} + +/// Each output goes to at most one connector; unmatched ones take the next free output of the same +/// size, else the next free one in layout order, since leaving them unaugmented keeps them all at +/// DRM's (0,0). +fn assign_wayland_outputs( + drm: &[DrmDisplayInfo], + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], +) -> Vec> { + let mut taken = vec![false; wl.len()]; + let mut matched: Vec> = vec![None; drm.len()]; + for (i, d) in drm.iter().enumerate() { + if let Some(j) = match_wayland_display(d, wl, &taken) { + matched[i] = Some(j); + taken[j] = true; + } + } + for (i, d) in drm.iter().enumerate() { + if matched[i].is_some() { + continue; + } + let free_same_size = wl + .iter() + .enumerate() + .position(|(j, w)| !taken[j] && w.width == d.width as i32 && w.height == d.height as i32); + let Some(j) = free_same_size.or_else(|| taken.iter().position(|t| !t)) else { + continue; // more connectors than outputs; leave the rest unaugmented + }; + log::warn!( + "drm: connector {} matched no compositor output by name or by a unique resolution; \ + falling back to layout order and taking {} at ({}, {})", + d.name, + wl[j].name, + wl[j].x, + wl[j].y + ); + matched[i] = Some(j); + taken[j] = true; + } + matched +} + +fn match_wayland_display( + d: &DrmDisplayInfo, + wl: &[hbb_common::platform::linux::WaylandDisplayInfo], + taken: &[bool], +) -> Option { + let dn = normalize_connector(&d.name); + if let Some((j, _)) = wl + .iter() + .enumerate() + .find(|(j, w)| !taken[*j] && normalize_connector(&w.name) == dn) + { + return Some(j); + } + let same_res: Vec = wl + .iter() + .enumerate() + .filter(|(j, w)| !taken[*j] && w.width == d.width as i32 && w.height == d.height as i32) + .map(|(j, _)| j) + .collect(); + if same_res.len() == 1 { + return Some(same_res[0]); + } + None +} + +/// DRM inserts a single-letter type discriminator the compositor drops ("HDMI-A-1" -> "HDMI-1"). +/// Only a *letter* folds: a single *digit* is an MST port index, so "DP-1-2" is not "DP-2". +fn normalize_connector(name: &str) -> String { + let parts: Vec<&str> = name.split('-').collect(); + if parts.len() == 3 && parts[1].len() == 1 && parts[1].chars().all(|c| c.is_ascii_alphabetic()) { + format!("{}-{}", parts[0], parts[2]) + } else { + name.to_string() + } +} + +fn swap_available_displays(list: Vec) { + let mut st = DRM_STATE.lock().unwrap(); + if matches!(&*st, ProbeState::Available(..)) { + if list.is_empty() { + log::info!("drm: hotplug refresh -> 0 displays, marking DRM unavailable"); + publish_probe_state(&mut st, ProbeState::Unavailable(Instant::now())); + } else { + log::info!("drm: hotplug refresh -> {} display(s)", list.len()); + publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list)); + } + } +} + +fn display_info_from_drm(d: &DrmDisplayInfo) -> DisplayInfo { + let original_resolution = + super::display_service::get_original_resolution(&d.name, d.width as usize, d.height as usize); + DisplayInfo { + x: d.x, + y: d.y, + width: d.width as i32, + height: d.height as i32, + name: d.name.clone(), + online: d.active, + cursor_embedded: false, + original_resolution, + scale: 1.0, + ..Default::default() + } +} + +/// Deliberately does NOT publish the handshake list into DRM_STATE: it is read before a possibly +/// seconds-long stall, and when `wire_idx != display_idx` it is ordered differently. +pub(super) fn get_capturer_info( + display_idx: usize, +) -> ResultType { + let expected = display_info_of(display_idx as i32); + let key = expected.as_ref().map(connector_key); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + if let Some(h) = key.as_ref().and_then(|k| map.get_mut(k)) { + if h.zero_frame_streak >= DRM_GRAB_MAX_FAILURES { + if h.demoted() { + bail!( + "drm capture for display {display_idx} repeatedly produced no frame; using PipeWire" + ); + } + h.zero_frame_streak = 0; + h.since = Instant::now(); + } + } + } + // Built FIRST: a transient `_drm` outage must NOT count toward the flap threshold below. + let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?; + // The initial build counts 0, so demotion fires on the (RAPID_REBUILD_MAX + 1)-th in a window. + if let Some(key) = key.clone() { + let now = Instant::now(); + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key).or_insert_with(DisplayHealth::new); + h.rapid_builds = match h.last_build { + Some(last) if now.duration_since(last) < RAPID_REBUILD_WINDOW => h.rapid_builds + 1, + _ => 0, + }; + h.last_build = Some(now); + if h.rapid_builds >= RAPID_REBUILD_MAX { + log::warn!( + "drm: display {display_idx} rebuilt {} times within {RAPID_REBUILD_WINDOW:?}; flapping, falling back to PipeWire", + h.rapid_builds + ); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.since = now; + h.demotes += 1; + bail!("drm capture for display {display_idx} is flapping; using PipeWire"); + } + } + let ndisplay = displays.len(); + // From the entry the stream was BOUND to; `display_idx` is a position in the CLIENT's list. + let d = displays + .get(wire_idx) + .ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))? + .clone(); + // Publish the compositor's LOGICAL origin (what get_display_infos advertises) so the origin + // matches the reported geometry; KEEP the raw PHYSICAL dimensions for the capture buffer. + let origin = augment_with_wayland_geometry(&displays) + .get(wire_idx) + .map(|di| (di.x, di.y)) + .unwrap_or((d.x, d.y)); + Ok(super::video_service::CapturerInfo { + origin, + width: d.width as usize, + height: d.height as usize, + ndisplay, + current: display_idx, + privacy_mode_id: 0, + _capturer_privacy_mode_id: 0, + capturer: Box::new(capturer), + }) +} + +#[cfg(test)] +mod drm_capturer_tests { + use super::*; + + fn capturer_with(session: Option<(usize, usize)>) -> IpcDrmCapturer { + capturer_named(session, None) + } + + // DRM_DISPLAY_HEALTH is process-wide and tests run in parallel: pass each test its OWN key. + fn capturer_named(session: Option<(usize, usize)>, key: Option<&str>) -> IpcDrmCapturer { + let connector = key.map(|k| k.to_owned()); + IpcDrmCapturer { + shared: Arc::new(Shared { + slot: Mutex::new(FrameSlot { + latest: None, + free: [None, None], + ended: None, + }), + cv: Condvar::new(), + }), + stop: Arc::new(AtomicBool::new(false)), + display: 0, + connector, + session_size: session, + cur: Vec::new(), + cur_w: 0, + cur_h: 0, + cur_fmt: Pixfmt::BGRA, + got_frame: false, + } + } + + fn zero_frame_streak_of(c: &IpcDrmCapturer) -> u32 { + let key = c.connector.clone().expect("this check needs an identity"); + DRM_DISPLAY_HEALTH + .lock() + .unwrap() + .get(&key) + .map(|h| h.zero_frame_streak) + .unwrap_or(0) + } + + fn put_frame(c: &IpcDrmCapturer, w: usize, h: usize) { + let mut buf = c.shared.slot.lock().unwrap().take_free().unwrap_or_default(); + buf.clear(); + buf.resize(w * h * 4, 0); + let mut slot = c.shared.slot.lock().unwrap(); + slot.publish(w, h, Pixfmt::BGRA, buf); + } + + #[test] + fn a_delivered_frame_clears_the_streak_but_keeps_the_cadence_and_the_convert_verdict() { + let key = "test:frame-keeps-cadence"; + let mut c = capturer_named(Some((64, 32)), Some(key)); + { + let mut map = DRM_DISPLAY_HEALTH.lock().unwrap(); + let h = map.entry(key.to_owned()).or_insert_with(DisplayHealth::new); + h.zero_frame_streak = 2; + h.demotes = 1; + h.rapid_builds = 3; + h.last_build = Some(Instant::now()); + h.prefer_cpu = true; + } + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + + // Copy out and RELEASE the guard before asserting: a failing assertion while holding + // process-wide DRM_DISPLAY_HEALTH poisons the mutex for every sibling test. + let h = { + let map = DRM_DISPLAY_HEALTH.lock().unwrap(); + *map.get(key).expect("the entry must SURVIVE a delivered frame") + }; + assert_eq!(h.zero_frame_streak, 0, "a delivered frame refutes the zero-frame streak"); + assert_eq!(h.demotes, 0, "and the demotion count that streak drove"); + assert_eq!( + h.rapid_builds, 3, + "but it says NOTHING about the rebuild cadence: keeping it is what lets the flap guard \ + reach RAPID_REBUILD_MAX for a display that delivers a first frame and then fails" + ); + assert!(h.last_build.is_some(), "same for the timestamp the cadence is measured from"); + assert!( + h.prefer_cpu, + "and nothing about which GPU exports the scanout: only a topology change may clear it" + ); + } + + #[test] + fn frame_of_the_session_size_is_delivered() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!( + matches!(c.frame(Duration::from_millis(50)), Ok(_)), + "a frame matching the session geometry must be delivered" + ); + assert!(c.got_frame); + } + + #[test] + fn a_smaller_frame_ends_the_session_instead_of_being_encoded() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:mid-session-shrink")); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a mid-session shrink must be a hard error, not a delivered frame"), + }; + assert!(err.to_string().contains("changed geometry mid-session")); + assert!( + c.got_frame, + "the rebuild must not look like a display that never produced a frame" + ); + assert_eq!( + zero_frame_streak_of(&c), + 0, + "a session that streamed must not be counted as one that produced nothing" + ); + } + + #[test] + fn a_first_frame_that_never_matched_counts_as_a_session_without_frames() { + let mut c = capturer_named(Some((1920, 1080)), Some("test:never-matched")); + put_frame(&c, 1280, 720); + let err = match c.frame(Duration::from_millis(50)) { + Err(e) => e, + Ok(_) => panic!("a first frame off the advertised geometry must be a hard error"), + }; + assert!(err.to_string().contains("never matched its advertised geometry")); + assert!(!c.got_frame, "no frame reached the encoder, so none was produced"); + assert_eq!( + zero_frame_streak_of(&c), + 1, + "the display must be on its way to a PipeWire demotion, not just rebuilding" + ); + } + + #[test] + fn a_larger_frame_ends_the_session_too() { + let mut c = capturer_with(Some((1280, 720))); + put_frame(&c, 1920, 1080); + assert!(matches!(c.frame(Duration::from_millis(50)), Err(_))); + } + + #[test] + fn unknown_session_size_delivers_whatever_arrives() { + let mut c = capturer_with(None); + put_frame(&c, 800, 600); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + } + + fn drm_display(name: &str, w: u32, h: u32) -> DrmDisplayInfo { + DrmDisplayInfo { + name: name.to_owned(), + crtc_id: 1, + x: 0, + y: 0, + width: w, + height: h, + active: true, + render_node: String::new(), + device: String::new(), + } + } + + fn wl_display( + name: &str, + x: i32, + y: i32, + w: i32, + h: i32, + ) -> hbb_common::platform::linux::WaylandDisplayInfo { + hbb_common::platform::linux::WaylandDisplayInfo { + name: name.to_owned(), + x, + y, + width: w, + height: h, + logical_size: Some((w, h)), + refresh_rate: 60, + } + } + + #[test] + fn frame_buffers_circulate_instead_of_being_reallocated() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + put_frame(&c, 64, 32); + let recycled = c + .shared + .slot + .lock() + .unwrap() + .free + .iter() + .find_map(|b| b.as_ref()) + .map(|b| b.as_ptr()); + assert!( + recycled.is_some(), + "a superseded frame must be handed back, not dropped" + ); + put_frame(&c, 64, 32); + assert_eq!( + c.shared + .slot + .lock() + .unwrap() + .latest + .as_ref() + .map(|(.., b)| b.as_ptr()), + recycled, + "the receive path must refill the recycled buffer rather than allocate" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert!( + c.shared.slot.lock().unwrap().free.iter().any(|b| b.is_some()), + "the buffer the encoder finished with must be handed back to the receive path" + ); + } + + // Against a single free slot this asserts red: counting the offers is the point. + #[test] + fn two_idle_buffers_are_both_kept_rather_than_one_being_dropped() { + let mut c = capturer_with(Some((64, 32))); + put_frame(&c, 64, 32); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + while c.shared.slot.lock().unwrap().take_free().is_some() {} + + put_frame(&c, 64, 32); // fills a fresh buffer (nothing on offer) and publishes it + put_frame(&c, 64, 32); // supersedes it -> deposit #1 + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 1, + "the superseded frame is the first idle buffer" + ); + assert!(matches!(c.frame(Duration::from_millis(50)), Ok(_))); + assert_eq!( + c.shared.slot.lock().unwrap().free.iter().flatten().count(), + 2, + "both idle buffers must be kept; a single slot dropped the older one" + ); + } + + #[test] + fn outputs_are_matched_by_name_across_the_drm_naming_difference() { + let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)]; + let wl = [wl_display("DP-1", 1920, 0, 2560, 1440), wl_display("HDMI-1", 0, 0, 1920, 1080)]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(1), Some(0)]); + } + + // The M10 case: same model and resolution, names that do not normalize to the compositor's. + #[test] + fn identical_monitors_that_match_no_name_take_layout_order() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn one_output_is_never_claimed_by_two_connectors() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("DP-2", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 3840, 2160), + ]; + let got = assign_wayland_outputs(&drm, &wl); + assert_eq!(got[0], Some(0)); + assert_ne!(got[0], got[1], "two connectors must not share one output"); + } + + #[test] + fn a_name_match_beats_the_positional_fallback() { + let drm = [drm_display("DP-1", 1920, 1080), drm_display("HDMI-A-1", 1920, 1080)]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("HDMI-1", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1)]); + } + + #[test] + fn extra_connectors_stay_unmatched() { + let drm = [ + drm_display("DP-1", 1920, 1080), + drm_display("DP-2", 1920, 1080), + drm_display("DP-3", 1920, 1080), + ]; + let wl = [ + wl_display("Unknown-1", 0, 0, 1920, 1080), + wl_display("Unknown-2", 1920, 0, 1920, 1080), + ]; + assert_eq!(assign_wayland_outputs(&drm, &wl), vec![Some(0), Some(1), None]); + } + + #[test] + fn refresh_keeps_a_verdict_through_one_failure_and_gives_it_up_after_a_run() { + assert_eq!(refresh_outcome(Some(3), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(1), 0), RefreshOutcome::Publish); + assert_eq!(refresh_outcome(Some(0), 0), RefreshOutcome::Unavailable); + assert_eq!(refresh_outcome(None, 1), RefreshOutcome::Restamp); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES - 1), + RefreshOutcome::Restamp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES), + RefreshOutcome::GiveUp + ); + assert_eq!( + refresh_outcome(None, DRM_REFRESH_MAX_FAILURES + 5), + RefreshOutcome::GiveUp + ); + } + + #[test] + fn a_dead_producer_stops_being_advertised() { + let mut outcome = RefreshOutcome::Restamp; + for failures in 1..=DRM_REFRESH_MAX_FAILURES { + outcome = refresh_outcome(None, failures); + } + assert_eq!(outcome, RefreshOutcome::GiveUp); + assert!( + DRM_REFRESH_MAX_FAILURES >= 2, + "a single transient failure must never be enough to drop the verdict" + ); + } + + #[test] + fn health_reports_demoted_only_while_the_cooldown_runs() { + let mut h = DisplayHealth::new(); + assert!(!h.demoted(), "a fresh display is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES - 1; + assert!(!h.demoted(), "one session short of the threshold is not demoted"); + h.zero_frame_streak = DRM_GRAB_MAX_FAILURES; + h.demotes = 1; + assert!(h.demoted(), "at the threshold, inside the cooldown"); + h.since = Instant::now() - demote_cooldown(h.demotes) - Duration::from_secs(1); + assert!(!h.demoted(), "past the cooldown the display must be retried"); + h.demotes = 4; + assert!(h.demoted(), "the backoff must still be holding it at demotion 4"); + } + + #[test] + fn demote_cooldown_doubles_per_cycle_and_caps() { + assert_eq!(demote_cooldown(1), DEMOTE_COOLDOWN); + assert_eq!(demote_cooldown(2), DEMOTE_COOLDOWN * 2); + assert_eq!(demote_cooldown(3), DEMOTE_COOLDOWN * 4); + let cap = DEMOTE_COOLDOWN * (1 << DEMOTE_BACKOFF_MAX_SHIFT); + assert_eq!(demote_cooldown(1 + DEMOTE_BACKOFF_MAX_SHIFT), cap); + assert_eq!(demote_cooldown(50), cap); + assert_eq!(demote_cooldown(u32::MAX), cap); + assert_eq!(demote_cooldown(0), DEMOTE_COOLDOWN); + } + + #[test] + fn a_permanently_ungrabbable_display_stops_churning() { + let burn = Duration::from_secs(5); // four failed sessions + assert!(demote_cooldown(1) + burn < Duration::from_secs(40)); + assert!(demote_cooldown(5) + burn > Duration::from_secs(8 * 60)); + } +} diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 1d4deeb65..aa6893f39 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -396,19 +396,62 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()> if let Some(hcursor) = crate::get_cursor()? { if hcursor != state.hcursor { let msg; + // On the DRM path get_cursor_data() may return a snapshot whose id has advanced past the + // requested `hcursor` (it returns the latest hardware cursor); file it in the cache AND + // record state.hcursor under the id ACTUALLY served, so a later reappearance of that exact + // shape dedupes correctly instead of being suppressed. Everything below is fully + // gated on the drm feature, so the drm-off build stays byte-identical to upstream. + #[cfg(all(target_os = "linux", feature = "drm"))] + let mut drm_served_id = hcursor; if let Some(cached) = state.cached_cursor_data.get(&hcursor) { super::log::trace!("Cursor data cached, hcursor: {}", hcursor); msg = cached.clone(); } else { let mut data = crate::get_cursor_data(hcursor)?; + // File the shape under the id ACTUALLY served, not the one requested. Deliberately a + // NEW name rather than shadowing `hcursor`: the insert below reads as the requested + // id everywhere else in this function, and a cfg-gated shadow would make the two + // builds disagree about what that line means. + #[cfg(all(target_os = "linux", feature = "drm"))] + let served_id = data.id; + #[cfg(all(target_os = "linux", feature = "drm"))] + { + drm_served_id = served_id; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + let cache_key = served_id; + #[cfg(not(all(target_os = "linux", feature = "drm")))] + let cache_key = hcursor; data.colors = hbb_common::compress::compress(&data.colors[..]).into(); let mut tmp = Message::new(); tmp.set_cursor_data(data); msg = Arc::new(tmp); - state.cached_cursor_data.insert(hcursor, msg.clone()); - super::log::trace!("Cursor data updated, hcursor: {}", hcursor); + // A DRM cursor id is derived from the shape's pixels plus geometry, so an animated + // pointer mints a new id on every shape change and this map would grow for the life + // of the service, each entry pinning a compressed cursor message. (Upstream's X11 + // ids come from a small set of XFixes serials, so the map is effectively bounded + // there -- which is why the ceiling is gated and the stock build stays untouched.) + // Past the ceiling, drop the map and start over: the next request for any evicted + // shape just recompresses it, and the ceiling comfortably covers every static shape + // plus a generous animation window. + #[cfg(all(target_os = "linux", feature = "drm"))] + { + const CURSOR_CACHE_MAX: usize = 64; + if state.cached_cursor_data.len() >= CURSOR_CACHE_MAX { + state.cached_cursor_data.clear(); + } + } + state.cached_cursor_data.insert(cache_key, msg.clone()); + super::log::trace!("Cursor data updated, hcursor: {}", cache_key); + } + #[cfg(not(all(target_os = "linux", feature = "drm")))] + { + state.hcursor = hcursor; + } + #[cfg(all(target_os = "linux", feature = "drm"))] + { + state.hcursor = drm_served_id; } - state.hcursor = hcursor; sp.send_shared(msg.clone()); state.cursor_data = msg; } diff --git a/src/server/wayland.rs b/src/server/wayland.rs index dacce9485..ffdf12c98 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -107,8 +107,81 @@ struct CapDisplayInfo { capturer: CapturerPtr, } +/// Set the uinput absolute-pointer range to the whole logical desktop so the compositor maps +/// injected coordinates 1:1 instead of stretching a single-monitor range across all outputs. The +/// PipeWire path does this inline in `check_init`; the DRM path bypasses check_init so it must do it +/// too, otherwise on a multi-monitor host the injected pointer lands on the wrong output — and the +/// hardware cursor, which lives on whichever CRTC the pointer is over, never appears on the captured +/// CRTC (the "cursor not visible" symptom). Reads the layout from the Wayland outputs, so it is +/// independent of the capture backend. +/// +/// This is the DRM path's single copy of what `check_init` does inline for PipeWire, and it does the +/// same three things, for the same reasons: +/// +/// - drops the cached Wayland layout first, because it can predate compositor changes made while no +/// session was active (rustdesk#15601), and on the hotplug path it is stale by definition; +/// - bounds the IPC wait, because `uinput::client::set_resolution` reads its reply with no timeout of +/// its own, so a hung uinput socket would otherwise block every video-service start on this branch +/// and wedge the hotplug worker inside `rt.block_on`, leaving `UINPUT_REFRESH_BUSY` latched true so +/// that every later hotplug refresh is silently skipped for the process lifetime; +/// - records the applied rect and snapshots the per-display layout baseline, which is what arms the +/// #15601 drift remap. Without it the remap never activates on the DRM path at all. +/// +/// It stays a separate copy rather than being folded into `check_init` because `check_init` ships in +/// every Linux build and this feature must not change the drm-off one by so much as a line. +#[cfg(feature = "drm")] +pub(super) async fn update_uinput_resolution() { + if !crate::input_service::wayland_use_uinput() { + return; + } + scrap::wayland::display::clear_wayland_displays_cache(); + let Some(rect) = scrap::wayland::display::get_desktop_rect_for_uinput() else { + log::warn!("Failed to get desktop rect for uinput"); + return; + }; + // Re-snapshot the baseline on every call: this runs at session init and after every hotplug, and + // the baseline is what the client's coordinates are measured against. + let snapshot_layout = || { + super::display_service::set_wayland_layout_baseline( + scrap::wayland::display::get_display_rects_for_uinput(), + ); + }; + // Reprogram the device only when the range actually changes. A display stuck in a rebuild loop + // calls this about once a second, and reapplying an identical range is an IPC roundtrip plus a + // uinput device reconfiguration under a user who may be at the console. + if super::display_service::wayland_uinput_rect() == Some(rect) { + snapshot_layout(); + return; + } + let (minx, maxx, miny, maxy) = rect; + log::info!("update mouse resolution: ({minx}, {maxx}), ({miny}, {maxy})"); + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + // Record the rect only after a successful apply, so a transient failure is retried on the + // next call instead of being remembered as applied. + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(rect); + snapshot_layout(); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } +} + #[tokio::main(flavor = "current_thread")] pub(super) async fn ensure_inited() -> ResultType<()> { + // DRM/KMS capture (opt-in): the root service owns the reader and the capturer self-inits over + // IPC, so there is no PipeWire recorder to initialize here. But we still must set the uinput + // desktop rect (check_init does this on the PipeWire path, and the DRM path skips check_init). + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + update_uinput_resolution().await; + return Ok(()); + } check_init().await } @@ -116,6 +189,10 @@ pub(super) fn is_inited() -> Option { if is_x11() { None } else { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + return None; + } if CAP_DISPLAY_INFO.read().unwrap().is_empty() { let mut msg_out = Message::new(); let res = MessageBox { @@ -242,6 +319,24 @@ pub(super) async fn check_init() -> ResultType<()> { } pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + // This function runs once per login (update_get_sync_displays_on_login is its only + // caller), and login is the moment the client is PROMISED a display list -- so refresh + // that list over a live `_drm` handshake first. The service wakes sleeping displays and + // answers with the settled truth, which is what makes an unattended box with an idled, + // DISABLED panel connectable at all: the cached list would either omit the panel (probed + // while asleep) or advertise a display with no scanout behind it (probed while awake), and + // either way the wake then firing inside the capture handshake would change the list the + // client had already been given. Properly async, so the executor is never blocked; on any + // failure the cache serves as before. + super::drm_capturer::refresh_displays_for_login().await; + if let Some(displays) = super::drm_capturer::get_display_infos() { + // DRM connector order is not the compositor's primary; resolve the real primary from + // the compositor layout (matched by normalized connector name), not a hardcoded index 0. + return Ok((displays, super::drm_capturer::get_primary_index())); + } + } check_init().await?; // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); @@ -260,6 +355,19 @@ pub fn clear() { if is_x11() { return; } + // The DRM path augments its geometry from the compositor's Wayland outputs (logical origin + + // scale), which scrap caches process-wide. The PipeWire path clears that cache on session close, + // but the DRM path opens no PipeWire session, so without this it would keep matching DRM outputs + // against STALE geometry after a monitor hotplug/rotation/scale change. Invalidate it on teardown + // so the next session re-reads fresh geometry (lazily, on the next enumeration) and self-heals. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available_cached() { + scrap::wayland::display::clear_wayland_displays_cache(); + } + // NOTE: intentionally do NOT reset the DRM probe cache here. `clear()` runs on every capturer + // teardown (which happens on each video-service restart), and re-probing `_drm` from the async + // enumeration path blocks the executor long enough to trip "deadline has elapsed" and spiral + // into a restart loop. DRM availability is fixed at service start, so the cache stays valid. let mut write_lock = CAP_DISPLAY_INFO.write().unwrap(); for (_, addr) in write_lock.iter() { let cap_display_info: *mut CapDisplayInfo = *addr as _; @@ -274,18 +382,136 @@ pub fn clear() { *PIPEWIRE_INITIALIZED.write().unwrap() = false; } +/// Initialize the PipeWire/portal capture path from the plain (sync) video thread, so a DRM display +/// that cannot be captured can fall through to PipeWire for THAT display. `ensure_inited` short-circuits +/// to the DRM branch whenever DRM is globally available, so it never runs `check_init`; this helper +/// drives the same async portal ScreenCast init directly (mirroring `ensure_inited`'s pattern). Needed +/// because `is_available()` is a GLOBAL verdict — it stays true for the still-working DRM outputs — so +/// without a per-display fallback a single failed/demoted DRM display would restart-loop the video +/// service instead of degrading to PipeWire only for itself. +#[cfg(feature = "drm")] +#[tokio::main(flavor = "current_thread")] +async fn ensure_pipewire_inited() -> ResultType<()> { + check_init().await +} + pub(super) fn get_capturer_for_display( display_idx: usize, ) -> ResultType { if is_x11() { bail!("Do not call this function if not wayland"); } + // DRM/KMS capture path: build the capturer straight from the service `_drm` stream, bypassing + // the PipeWire CAP_DISPLAY_INFO machinery entirely. `is_available()` is a GLOBAL verdict, so a + // per-display DRM failure (an ungrabbable/demoted CRTC, or — after the phase-2 split — a + // render-node-absent seat or a convert failure on the unprivileged side) must NOT propagate out + // and restart-loop this per-display video service. Instead fall THROUGH to PipeWire for just this + // display; the other DRM outputs keep streaming over DRM. + // The ONE gate that keeps the probing form on purpose: this runs on the plain video thread, + // not an async executor, and it is the capture-build path, so a definitive verdict is worth + // seconds here. It is also what makes a cold cache recoverable at all -- warm_availability + // gives up after its attempts, so if EVERY gate were cache-only a --server that started + // before the root service would never see DRM again for the rest of its life. + #[cfg(feature = "drm")] + if super::drm_capturer::is_available() { + match super::drm_capturer::get_capturer_info(display_idx) { + Ok(info) => return Ok(info), + Err(e) => { + log::warn!( + "drm capturer for display {} unavailable ({:#}); falling back to PipeWire", + display_idx, + e + ); + ensure_pipewire_inited()?; + } + } + } + // Resolved BEFORE the read guard below, deliberately. `get_display_infos` runs + // `augment_with_wayland_geometry`, which is a compositor output roundtrip, and `clear()` takes + // the WRITE guard on every capturer teardown -- which is exactly what is happening when a DRM + // display is demoted or flapping, i.e. precisely when this path runs. Holding the read guard + // across that roundtrip would stall every concurrent teardown for its duration, and the value + // does not depend on anything inside the guard. + #[cfg(feature = "drm")] + let drm_advertised = if super::drm_capturer::is_available_cached() { + match super::drm_capturer::get_display_infos() { + Some(list) => Some((list.get(display_idx).cloned(), list.len() == 1)), + None => Some((None, false)), + } + } else { + None + }; let cap_map = CAP_DISPLAY_INFO.read().unwrap(); + // Serve ONLY the exact PipeWire entry for this index. Do NOT fall back to another index's + // `CapDisplayInfo`: `CapturerPtr` is a bare `*mut Capturer` cloned by raw-pointer copy, so aliasing + // one entry to two `display_idx` values would let two video-service threads call `frame()` on the + // same `Recorder` with no lock (data race / UB), and it would also mis-map input against the wrong + // rect. DRM and PipeWire do not share an index space (the portal often exposes one whole-desktop + // stream at index 0), so a demoted non-primary DRM index has no PipeWire entry here; that case is + // handled at the source by dropping the demoted display from the advertised list (see + // drm_capturer demotion) so the client re-enumerates against a consistent list, rather than being + // papered over with a shared/mismatched capturer. if let Some(addr) = cap_map.get(&display_idx) { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; let rect = cap_display_info.rects[cap_display_info.current]; + // Reaching here with DRM active means get_capturer_info bailed (a demoted display) and + // we fell through to PipeWire. Serve this stream ONLY if its rect matches the + // geometry we advertised for this index. The portal typically exposes one whole-desktop + // stream, so on a multi-monitor host that rect is the FULL desktop while the advertised DRM + // geometry is a single connector -> serving it would stretch the frame and offset all + // input. Bail instead; get_display_infos advertised the display offline, so the client + // re-enumerates against a consistent list. A single-display host matches (whole-desktop == + // that display) and is served normally. On a pure-PipeWire host is_available() is false and + // this guard is skipped, preserving upstream behavior exactly. + #[cfg(feature = "drm")] + if let Some((advertised, single_display)) = drm_advertised { + if let Some(advertised) = advertised { + // BOTH SIDES ARE PHYSICAL, so compare them raw. Traced rather than assumed, + // because it was twice "corrected" to a scale conversion that broke it: + // `rect` is built above from `Display::width()/height()`, and the WAYLAND + // variant of those returns `physical_width()/physical_height()` + // (scrap `common/wayland.rs`), i.e. `PipeWireCapturable.physical_size`. + // `try_fix_logical_size` only repairs the capturable's SEPARATE + // `logical_size` field and never touches `physical_size`, so the rect is not + // logical. The advertised DRM geometry is physical too + // (`augment_with_wayland_geometry` sets x/y/scale and deliberately leaves + // width/height as the DRM mode). Dividing one side by the scale therefore + // compares logical against physical and rejects the valid stream on exactly + // the scaled outputs it was meant to rescue. + // + // The size check is what tells one connector apart from the whole-desktop + // rect the portal usually exposes. It is skipped only when BOTH sides say + // there is a single display -- the DRM list has one entry and the PipeWire + // map has one -- because only then is "the whole-desktop stream IS this + // display" true by construction. (The portal can report a different physical + // size for a Full Workspace selection than the connector's mode, which is why + // that case needs the carve-out at all.) The DRM count alone is not enough: + // a monitor on a card the service cannot open is missing from the DRM list + // while the compositor still drives it. + let single_display = single_display && cap_display_info.num == 1; + let consistent = advertised.x == rect.0 .0 + && advertised.y == rect.0 .1 + && (single_display + || (advertised.width as usize == rect.1 + && advertised.height as usize == rect.2)); + if !consistent { + bail!( + "drm display {} demoted with no geometry-consistent PipeWire stream (advertised {}x{}+{}+{} vs stream {}x{}+{}+{}); advertised offline", + display_idx, + advertised.width, + advertised.height, + advertised.x, + advertised.y, + rect.1, + rect.2, + rect.0 .0, + rect.0 .1 + ); + } + } + } Ok(super::video_service::CapturerInfo { origin: rect.0, width: rect.1,