From b03edcfdc55864a78f46d4449e26887f469f69a3 Mon Sep 17 00:00:00 2001 From: roshan-ku Date: Fri, 16 Oct 2026 21:55:22 +0530 Subject: [PATCH 1/2] feat: integrate receiver application and build tooling --- .github/actions/analysis/afl-fuzz/action.yml | 53 +- .github/actions/analysis/checksec/action.yml | 6 +- .github/actions/analysis/codeql/action.yml | 4 +- .github/actions/analysis/cppcheck/action.yml | 3 +- .github/actions/analysis/libfuzzer/action.yml | 64 +- .github/actions/build-dvledtx/action.yml | 22 +- .github/actions/environment-check/action.yml | 8 +- .github/actions/smoke-tests/action.yml | 12 +- .github/workflows/daily_build.yml | 20 +- .gitignore | 2 + README.md | 53 +- dvledrx/config/rx_2k_1session.json | 24 + dvledrx/config/rx_4k_1session.json | 24 + .../rx_4k_2nic_2sessions_per_output.json | 33 + dvledrx/config/rx_8nic_8sessions.json | 30 + dvledrx/config/rx_crop_1session.json | 24 + dvledrx/config/rx_crop_3sessions.json | 34 + .../config/rx_crop_3sessions_per_output.json | 34 + dvledrx/config/rx_fullhd_1session.json | 24 + dvledrx/fuzz/corpus/displays_mirrors.json | 1 + dvledrx/fuzz/corpus/malformed.json | 1 + dvledrx/fuzz/corpus/multi_session.json | 1 + dvledrx/fuzz/corpus/valid_single.json | 1 + dvledrx/fuzz/fuzz_config_reader.c | 55 + dvledrx/fuzz/fuzz_config_reader_common.h | 51 + dvledrx/fuzz/fuzz_config_reader_libfuzzer.c | 12 + dvledrx/include/config_reader.h | 94 ++ dvledrx/include/frame_converter.h | 25 + dvledrx/include/logger.h | 52 + dvledrx/include/session_manager.h | 98 ++ dvledrx/include/x11_handler.h | 149 ++ dvledrx/meson.build | 44 + dvledrx/src/config_reader.c | 427 +++++ dvledrx/src/frame_converter.c | 373 +++++ dvledrx/src/logger.c | 401 +++++ dvledrx/src/main.c | 282 ++++ dvledrx/src/session_manager.c | 949 +++++++++++ dvledrx/src/x11_handler.c | 1455 +++++++++++++++++ dvledrx/tests/data/run_composite.json | 9 + dvledrx/tests/data/run_fps24.json | 5 + dvledrx/tests/data/run_fps29.json | 5 + dvledrx/tests/data/run_fps30.json | 5 + dvledrx/tests/data/run_fps59.json | 5 + dvledrx/tests/data/run_per_output.json | 18 + dvledrx/tests/data/run_single.json | 7 + dvledrx/tests/fake_mtl.c | 183 +++ dvledrx/tests/fake_mtl.h | 37 + dvledrx/tests/meson.build | 127 ++ dvledrx/tests/test_common.h | 119 ++ dvledrx/tests/test_config_reader.c | 482 ++++++ dvledrx/tests/test_frame_converter.c | 376 +++++ dvledrx/tests/test_logger.c | 343 ++++ dvledrx/tests/test_session_manager.c | 435 +++++ dvledrx/tests/test_x11.c | 480 ++++++ .../config}/tx_1session_12bit.json | 0 {config => dvledtx/config}/tx_2k_2nic.json | 0 .../config}/tx_2k_multi_session.json | 0 .../config}/tx_2k_single_session.json | 0 .../config}/tx_4k_multi_session.json | 0 .../config}/tx_4k_single_session.json | 0 .../config}/tx_fullhd_multi_nic.json | 0 .../config}/tx_fullhd_multi_session.json | 0 .../config}/tx_fullhd_screen_capture.json | 0 ...x_fullhd_screen_capture_multi_session.json | 0 .../config}/tx_fullhd_single_session.json | 0 .../config}/tx_gbrp12le_pf_8session.json | 0 .../fuzz}/corpus/empty_object.json | 0 .../fuzz}/corpus/fmt_gbrp10le.json | 0 .../fuzz}/corpus/fmt_yuv422p12le.json | 0 .../fuzz}/corpus/fmt_yuv444p10le.json | 0 {fuzz => dvledtx/fuzz}/corpus/malformed.json | 0 {fuzz => dvledtx/fuzz}/corpus/multi_nic.json | 0 .../fuzz}/corpus/multi_session.json | 0 {fuzz => dvledtx/fuzz}/corpus/ptp_config.json | 0 .../fuzz}/corpus/screen_capture.json | 0 .../fuzz}/corpus/valid_config.json | 0 {fuzz => dvledtx/fuzz}/fuzz_config_reader.c | 0 .../fuzz}/fuzz_config_reader_libfuzzer.c | 0 {include => dvledtx/include}/app_context.h | 0 .../include}/core/session_manager.h | 0 .../include}/ffmpeg/ffmpeg_decoder.h | 0 .../include}/ffmpeg/ffmpeg_frame_handler.h | 0 .../include}/ffmpeg/ffmpeg_tx.h | 0 {include => dvledtx/include}/mtl/mtl_tx.h | 0 .../include}/util/config_reader.h | 0 {include => dvledtx/include}/util/logger.h | 0 dvledtx/meson.build | 33 + {src => dvledtx/src}/core/session_manager.c | 0 {src => dvledtx/src}/ffmpeg/ffmpeg_decoder.c | 0 .../src}/ffmpeg/ffmpeg_frame_handler.c | 0 {src => dvledtx/src}/ffmpeg/ffmpeg_tx.c | 0 {src => dvledtx/src}/main.c | 0 {src => dvledtx/src}/mtl/mtl_tx.c | 0 {src => dvledtx/src}/util/config_reader.c | 0 {src => dvledtx/src}/util/logger.c | 0 dvledtx/tests/meson.build | 152 ++ {tests => dvledtx/tests}/test_config_reader.c | 0 .../tests}/test_ffmpeg_decoder.c | 0 .../tests}/test_ffmpeg_decoder_mock.c | 0 .../tests}/test_ffmpeg_frame_handler.c | 0 {tests => dvledtx/tests}/test_ffmpeg_tx.c | 0 .../tests}/test_ffmpeg_tx_mock.c | 0 .../tests}/test_ffmpeg_watchdog.c | 0 {tests => dvledtx/tests}/test_logger.c | 0 {tests => dvledtx/tests}/test_main.c | 0 {tests => dvledtx/tests}/test_mtl_tx.c | 0 {tests => dvledtx/tests}/test_mtl_tx_stub.c | 0 .../tests}/test_session_manager.c | 0 meson.build | 32 +- scripts/build.sh | 14 +- scripts/test.sh | 28 +- tests/meson.build | 212 --- 112 files changed, 7728 insertions(+), 324 deletions(-) create mode 100644 dvledrx/config/rx_2k_1session.json create mode 100644 dvledrx/config/rx_4k_1session.json create mode 100644 dvledrx/config/rx_4k_2nic_2sessions_per_output.json create mode 100644 dvledrx/config/rx_8nic_8sessions.json create mode 100644 dvledrx/config/rx_crop_1session.json create mode 100644 dvledrx/config/rx_crop_3sessions.json create mode 100644 dvledrx/config/rx_crop_3sessions_per_output.json create mode 100644 dvledrx/config/rx_fullhd_1session.json create mode 100644 dvledrx/fuzz/corpus/displays_mirrors.json create mode 100644 dvledrx/fuzz/corpus/malformed.json create mode 100644 dvledrx/fuzz/corpus/multi_session.json create mode 100644 dvledrx/fuzz/corpus/valid_single.json create mode 100644 dvledrx/fuzz/fuzz_config_reader.c create mode 100644 dvledrx/fuzz/fuzz_config_reader_common.h create mode 100644 dvledrx/fuzz/fuzz_config_reader_libfuzzer.c create mode 100644 dvledrx/include/config_reader.h create mode 100644 dvledrx/include/frame_converter.h create mode 100644 dvledrx/include/logger.h create mode 100644 dvledrx/include/session_manager.h create mode 100644 dvledrx/include/x11_handler.h create mode 100644 dvledrx/meson.build create mode 100644 dvledrx/src/config_reader.c create mode 100644 dvledrx/src/frame_converter.c create mode 100644 dvledrx/src/logger.c create mode 100644 dvledrx/src/main.c create mode 100644 dvledrx/src/session_manager.c create mode 100644 dvledrx/src/x11_handler.c create mode 100644 dvledrx/tests/data/run_composite.json create mode 100644 dvledrx/tests/data/run_fps24.json create mode 100644 dvledrx/tests/data/run_fps29.json create mode 100644 dvledrx/tests/data/run_fps30.json create mode 100644 dvledrx/tests/data/run_fps59.json create mode 100644 dvledrx/tests/data/run_per_output.json create mode 100644 dvledrx/tests/data/run_single.json create mode 100644 dvledrx/tests/fake_mtl.c create mode 100644 dvledrx/tests/fake_mtl.h create mode 100644 dvledrx/tests/meson.build create mode 100644 dvledrx/tests/test_common.h create mode 100644 dvledrx/tests/test_config_reader.c create mode 100644 dvledrx/tests/test_frame_converter.c create mode 100644 dvledrx/tests/test_logger.c create mode 100644 dvledrx/tests/test_session_manager.c create mode 100644 dvledrx/tests/test_x11.c rename {config => dvledtx/config}/tx_1session_12bit.json (100%) rename {config => dvledtx/config}/tx_2k_2nic.json (100%) rename {config => dvledtx/config}/tx_2k_multi_session.json (100%) rename {config => dvledtx/config}/tx_2k_single_session.json (100%) rename {config => dvledtx/config}/tx_4k_multi_session.json (100%) rename {config => dvledtx/config}/tx_4k_single_session.json (100%) rename {config => dvledtx/config}/tx_fullhd_multi_nic.json (100%) rename {config => dvledtx/config}/tx_fullhd_multi_session.json (100%) rename {config => dvledtx/config}/tx_fullhd_screen_capture.json (100%) rename {config => dvledtx/config}/tx_fullhd_screen_capture_multi_session.json (100%) rename {config => dvledtx/config}/tx_fullhd_single_session.json (100%) rename {config => dvledtx/config}/tx_gbrp12le_pf_8session.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/empty_object.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/fmt_gbrp10le.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/fmt_yuv422p12le.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/fmt_yuv444p10le.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/malformed.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/multi_nic.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/multi_session.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/ptp_config.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/screen_capture.json (100%) rename {fuzz => dvledtx/fuzz}/corpus/valid_config.json (100%) rename {fuzz => dvledtx/fuzz}/fuzz_config_reader.c (100%) rename {fuzz => dvledtx/fuzz}/fuzz_config_reader_libfuzzer.c (100%) rename {include => dvledtx/include}/app_context.h (100%) rename {include => dvledtx/include}/core/session_manager.h (100%) rename {include => dvledtx/include}/ffmpeg/ffmpeg_decoder.h (100%) rename {include => dvledtx/include}/ffmpeg/ffmpeg_frame_handler.h (100%) rename {include => dvledtx/include}/ffmpeg/ffmpeg_tx.h (100%) rename {include => dvledtx/include}/mtl/mtl_tx.h (100%) rename {include => dvledtx/include}/util/config_reader.h (100%) rename {include => dvledtx/include}/util/logger.h (100%) create mode 100644 dvledtx/meson.build rename {src => dvledtx/src}/core/session_manager.c (100%) rename {src => dvledtx/src}/ffmpeg/ffmpeg_decoder.c (100%) rename {src => dvledtx/src}/ffmpeg/ffmpeg_frame_handler.c (100%) rename {src => dvledtx/src}/ffmpeg/ffmpeg_tx.c (100%) rename {src => dvledtx/src}/main.c (100%) rename {src => dvledtx/src}/mtl/mtl_tx.c (100%) rename {src => dvledtx/src}/util/config_reader.c (100%) rename {src => dvledtx/src}/util/logger.c (100%) create mode 100644 dvledtx/tests/meson.build rename {tests => dvledtx/tests}/test_config_reader.c (100%) rename {tests => dvledtx/tests}/test_ffmpeg_decoder.c (100%) rename {tests => dvledtx/tests}/test_ffmpeg_decoder_mock.c (100%) rename {tests => dvledtx/tests}/test_ffmpeg_frame_handler.c (100%) rename {tests => dvledtx/tests}/test_ffmpeg_tx.c (100%) rename {tests => dvledtx/tests}/test_ffmpeg_tx_mock.c (100%) rename {tests => dvledtx/tests}/test_ffmpeg_watchdog.c (100%) rename {tests => dvledtx/tests}/test_logger.c (100%) rename {tests => dvledtx/tests}/test_main.c (100%) rename {tests => dvledtx/tests}/test_mtl_tx.c (100%) rename {tests => dvledtx/tests}/test_mtl_tx_stub.c (100%) rename {tests => dvledtx/tests}/test_session_manager.c (100%) delete mode 100644 tests/meson.build diff --git a/.github/actions/analysis/afl-fuzz/action.yml b/.github/actions/analysis/afl-fuzz/action.yml index e36c34e..c1b0f2d 100644 --- a/.github/actions/analysis/afl-fuzz/action.yml +++ b/.github/actions/analysis/afl-fuzz/action.yml @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause # name: 'AFL Fuzz' -description: 'Build and run AFL++ fuzzing for config parser harness' +description: 'Build and run AFL++ fuzzing for transmitter and receiver config parsers' inputs: max-seconds: @@ -19,7 +19,7 @@ runs: shell: bash run: | sudo apt-get update -qq - sudo apt-get install -y -qq afl++ libavutil-dev libavformat-dev libavcodec-dev libswscale-dev pkg-config + sudo apt-get install -y -qq afl++ libavutil-dev libavformat-dev libavcodec-dev libswscale-dev libjson-c-dev zlib1g-dev pkg-config - name: Verify AFL environment shell: bash @@ -40,7 +40,7 @@ runs: - name: Build AFL fuzz harness shell: bash run: | - cd fuzz + cd dvledtx/fuzz export CC=afl-clang-fast $CC -g -O1 -fno-omit-frame-pointer -I../include -c fuzz_config_reader.c -o fuzz_config_reader.o $CC -g -O1 -fno-omit-frame-pointer -I../include -c ../src/util/config_reader.c -o config_reader.o @@ -48,12 +48,19 @@ runs: $CC -o fuzz_config_reader fuzz_config_reader.o config_reader.o logger.o -lavutil -lm echo "Build successful: $(file fuzz_config_reader)" + cd ../../dvledrx/fuzz + $CC -g -O1 -fno-omit-frame-pointer -I../include \ + fuzz_config_reader.c ../src/config_reader.c ../src/logger.c \ + -pthread $(pkg-config --cflags --libs json-c zlib) \ + -o fuzz_config_reader + echo "Receiver build successful: $(file fuzz_config_reader)" + - name: Run AFL fuzzer shell: bash env: MAX_SECONDS: ${{ inputs.max-seconds }} run: | - cd fuzz + cd dvledtx/fuzz if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then echo "ERROR: max-seconds must be a positive integer" exit 1 @@ -66,7 +73,7 @@ runs: - name: Check AFL crashes shell: bash run: | - cd fuzz + cd dvledtx/fuzz CRASH_COUNT=$(find findings/default/crashes -type f ! -name "README.txt" 2>/dev/null | wc -l) echo "Crashes found: $CRASH_COUNT" if [ "$CRASH_COUNT" -gt 0 ]; then @@ -76,11 +83,41 @@ runs: fi echo "No crashes found - fuzzing passed." + - name: Run receiver AFL fuzzer + shell: bash + env: + MAX_SECONDS: ${{ inputs.max-seconds }} + run: | + cd dvledrx/fuzz + if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then + echo "ERROR: max-seconds must be a positive integer" + exit 1 + fi + mkdir -p findings + export AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 + export AFL_SKIP_CPUFREQ=1 + timeout "$MAX_SECONDS" afl-fuzz -i corpus/ -o findings/ -V "$MAX_SECONDS" -- ./fuzz_config_reader @@ || true + + - name: Check receiver AFL crashes + shell: bash + run: | + cd dvledrx/fuzz + CRASH_COUNT=$(find findings/default/crashes -type f ! -name "README.txt" 2>/dev/null | wc -l) + echo "Receiver crashes found: $CRASH_COUNT" + if [ "$CRASH_COUNT" -gt 0 ]; then + echo "::error::AFL found $CRASH_COUNT receiver crash(es)!" + ls -la findings/default/crashes/ + exit 1 + fi + echo "No receiver crashes found - fuzzing passed." + - name: Sanitize AFL filenames if: always() shell: bash run: | - cd fuzz/findings - find . -name '*:*' | while read -r f; do - mv "$f" "$(echo "$f" | tr ':' '_')" + for FINDINGS_DIR in dvledtx/fuzz/findings dvledrx/fuzz/findings; do + [ -d "$FINDINGS_DIR" ] || continue + find "$FINDINGS_DIR" -name '*:*' -print0 | while IFS= read -r -d '' f; do + mv "$f" "$(echo "$f" | tr ':' '_')" + done done diff --git a/.github/actions/analysis/checksec/action.yml b/.github/actions/analysis/checksec/action.yml index b5a6d7d..583e2be 100644 --- a/.github/actions/analysis/checksec/action.yml +++ b/.github/actions/analysis/checksec/action.yml @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause # name: 'checksec Analysis' -description: 'Run checksec static hardening analysis on dvledtx binaries' +description: 'Run checksec static hardening analysis on dvledtx and dvledrx binaries' runs: using: composite @@ -40,7 +40,7 @@ runs: echo "==================================================" echo " checksec � Binary Hardening Analysis" echo "==================================================" - for BIN in bins/dvledtx bins/dvledtx-mtl; do + for BIN in bins/dvledtx bins/dvledtx-mtl bins/dvledrx; do if [ -f "$BIN" ]; then echo "" echo "--- $BIN ---" @@ -59,7 +59,7 @@ runs: run: | REPORT_DIR="$GITHUB_WORKSPACE/reports" mkdir -p "$REPORT_DIR" - for BIN in bins/dvledtx bins/dvledtx-mtl; do + for BIN in bins/dvledtx bins/dvledtx-mtl bins/dvledrx; do if [ -f "$BIN" ]; then LABEL=$(basename "$BIN") checksec --no-banner file -o json "$BIN" 2>/dev/null \ diff --git a/.github/actions/analysis/codeql/action.yml b/.github/actions/analysis/codeql/action.yml index 6888a9b..b410b27 100644 --- a/.github/actions/analysis/codeql/action.yml +++ b/.github/actions/analysis/codeql/action.yml @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause # name: 'CodeQL Scan' -description: 'Initialize CodeQL, build dvledtx, and perform CodeQL analysis for C/C++' +description: 'Initialize CodeQL, build dvledtx and dvledrx, and perform CodeQL analysis for C/C++' runs: using: composite @@ -16,7 +16,7 @@ runs: build-mode: manual # c-cpp requires a manual build so CodeQL can trace the meson/ninja compiler invocations. - - name: Build dvledtx + - name: Build applications uses: $/.github/actions/build-dvledtx - name: Perform CodeQL Analysis diff --git a/.github/actions/analysis/cppcheck/action.yml b/.github/actions/analysis/cppcheck/action.yml index 254f8b5..c5e9314 100644 --- a/.github/actions/analysis/cppcheck/action.yml +++ b/.github/actions/analysis/cppcheck/action.yml @@ -31,5 +31,6 @@ runs: --enable=warning,style,performance,portability \ --std=c11 --force --inline-suppr \ --suppress=missingIncludeSystem \ - -Iinclude src 2>&1 | tee "$REPORT_DIR/cppcheck-report.txt" || true + -Idvledtx/include -Idvledrx/include \ + dvledtx/src dvledrx/src 2>&1 | tee "$REPORT_DIR/cppcheck-report.txt" || true echo "cppcheck scan complete. Report: $REPORT_DIR/cppcheck-report.txt" \ No newline at end of file diff --git a/.github/actions/analysis/libfuzzer/action.yml b/.github/actions/analysis/libfuzzer/action.yml index 7f27c06..0cb234b 100644 --- a/.github/actions/analysis/libfuzzer/action.yml +++ b/.github/actions/analysis/libfuzzer/action.yml @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause # name: 'libFuzzer' -description: 'Build and run libFuzzer with ASan and UBSan for config parser harness' +description: 'Build and run libFuzzer with ASan and UBSan for transmitter and receiver config parsers' inputs: max-seconds: @@ -19,7 +19,7 @@ runs: shell: bash run: | sudo apt-get update -qq - sudo apt-get install -y -qq clang libavutil-dev libavformat-dev libavcodec-dev libswscale-dev pkg-config + sudo apt-get install -y -qq clang libavutil-dev libavformat-dev libavcodec-dev libswscale-dev libjson-c-dev zlib1g-dev pkg-config - name: Build and run libFuzzer (ASan) shell: bash @@ -30,7 +30,7 @@ runs: echo "ERROR: max-seconds must be a positive integer" exit 1 fi - cd fuzz + cd dvledtx/fuzz export CC=clang $CC -fsanitize=fuzzer,address -g -O1 -fno-omit-frame-pointer -I../include \ fuzz_config_reader_libfuzzer.c \ @@ -58,7 +58,7 @@ runs: echo "ERROR: max-seconds must be a positive integer" exit 1 fi - cd fuzz + cd dvledtx/fuzz export CC=clang $CC -fsanitize=fuzzer,undefined -g -O1 -fno-omit-frame-pointer -I../include \ fuzz_config_reader_libfuzzer.c \ @@ -76,3 +76,59 @@ runs: echo "::error::libFuzzer+UBSan found an issue (exit code $FUZZ_EXIT)!" exit 1 fi + + - name: Build and run receiver libFuzzer (ASan) + shell: bash + env: + MAX_SECONDS: ${{ inputs.max-seconds }} + run: | + if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then + echo "ERROR: max-seconds must be a positive integer" + exit 1 + fi + cd dvledrx/fuzz + export CC=clang + $CC -fsanitize=fuzzer,address -g -O1 -fno-omit-frame-pointer -I../include \ + fuzz_config_reader_libfuzzer.c \ + ../src/config_reader.c \ + ../src/logger.c \ + -pthread -o fuzz_config_reader_libfuzzer \ + $(pkg-config --cflags --libs json-c zlib) + + mkdir -p libfuzzer_corpus + ./fuzz_config_reader_libfuzzer \ + libfuzzer_corpus/ corpus/ \ + -max_total_time="$MAX_SECONDS" \ + -print_final_stats=1 || FUZZ_EXIT=$? + if [ "${FUZZ_EXIT:-0}" -ne 0 ]; then + echo "::error::Receiver libFuzzer+ASan found an issue (exit code $FUZZ_EXIT)!" + exit 1 + fi + + - name: Build and run receiver libFuzzer (UBSan) + shell: bash + env: + MAX_SECONDS: ${{ inputs.max-seconds }} + run: | + if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then + echo "ERROR: max-seconds must be a positive integer" + exit 1 + fi + cd dvledrx/fuzz + export CC=clang + $CC -fsanitize=fuzzer,undefined -g -O1 -fno-omit-frame-pointer -I../include \ + fuzz_config_reader_libfuzzer.c \ + ../src/config_reader.c \ + ../src/logger.c \ + -pthread -o fuzz_config_reader_ubsan \ + $(pkg-config --cflags --libs json-c zlib) + + mkdir -p ubsan_corpus + ./fuzz_config_reader_ubsan \ + ubsan_corpus/ corpus/ \ + -max_total_time="$MAX_SECONDS" \ + -print_final_stats=1 || FUZZ_EXIT=$? + if [ "${FUZZ_EXIT:-0}" -ne 0 ]; then + echo "::error::Receiver libFuzzer+UBSan found an issue (exit code $FUZZ_EXIT)!" + exit 1 + fi diff --git a/.github/actions/build-dvledtx/action.yml b/.github/actions/build-dvledtx/action.yml index e740de8..db91dd6 100644 --- a/.github/actions/build-dvledtx/action.yml +++ b/.github/actions/build-dvledtx/action.yml @@ -3,8 +3,8 @@ # Copyright (C) 2026 Intel Corporation # SPDX-License-Identifier: BSD-3-Clause # -name: 'Build dvledtx' -description: 'Build dvledtx with FFMPEG and MTL TX APIs, keep both binaries' +name: 'Build dvledtx and dvledrx' +description: 'Build both applications with FFMPEG and MTL TX APIs' runs: using: composite @@ -14,23 +14,33 @@ runs: run: | echo "===== Build: FFMPEG TX APIs =====" ./scripts/build.sh - if [ ! -f "./build/dvledtx" ]; then + if [ ! -f "./build/dvledtx/dvledtx" ]; then echo "ERROR: dvledtx not found after FFMPEG build!" exit 1 fi + if [ ! -f "./build/dvledrx/dvledrx" ]; then + echo "ERROR: dvledrx not found after FFMPEG build!" + exit 1 + fi mkdir -p ./bins - cp ./build/dvledtx ./bins/dvledtx + cp ./build/dvledtx/dvledtx ./bins/dvledtx + cp ./build/dvledrx/dvledrx ./bins/dvledrx echo "FFMPEG build saved: $(file ./bins/dvledtx)" + echo "Receiver build saved: $(file ./bins/dvledrx)" - name: Build with MTL TX APIs shell: bash run: | echo "===== Build: MTL TX APIs =====" ./scripts/build.sh -Denable_mtl_tx=true - if [ ! -f "./build/dvledtx" ]; then + if [ ! -f "./build/dvledtx/dvledtx" ]; then echo "ERROR: dvledtx not found after MTL build!" exit 1 fi + if [ ! -f "./build/dvledrx/dvledrx" ]; then + echo "ERROR: dvledrx not found after MTL build!" + exit 1 + fi mkdir -p ./bins - cp ./build/dvledtx ./bins/dvledtx-mtl + cp ./build/dvledtx/dvledtx ./bins/dvledtx-mtl echo "MTL build saved: $(file ./bins/dvledtx-mtl)" diff --git a/.github/actions/environment-check/action.yml b/.github/actions/environment-check/action.yml index 99f9c99..2ab565a 100644 --- a/.github/actions/environment-check/action.yml +++ b/.github/actions/environment-check/action.yml @@ -10,11 +10,11 @@ inputs: dpdk-version: description: 'DPDK version to build' required: false - default: '25.11' + default: '26.07' mtl-commit: description: 'MTL commit ID to build' required: false - default: '7bee24231c864d12da6db2c2ffbfd3986717af5a' + default: '9762c314ed24bd628d3f808b95a94c3aa8525723' ffmpeg-version: description: 'FFmpeg release branch to build' required: false @@ -25,7 +25,7 @@ inputs: cached environment (e.g. new APT packages needed by the FFmpeg build) so runners rebuild instead of restoring a stale cache. required: false - default: '2' + default: '3' runs: using: composite @@ -111,7 +111,7 @@ runs: shell: bash run: | echo "===== APT Package Setup =====" - PKGS="git gcc meson python3 python3-pip pkg-config libnuma-dev libjson-c-dev libpcap-dev libgtest-dev libssl-dev systemtap-sdt-dev llvm clang flex byacc libcmocka-dev nasm wget patch unzip shellcheck cppcheck libx11-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev" + PKGS="git gcc meson python3 python3-pip pkg-config libnuma-dev libjson-c-dev libpcap-dev libgtest-dev libssl-dev systemtap-sdt-dev llvm clang flex byacc libcmocka-dev nasm wget patch unzip shellcheck cppcheck libx11-dev libxext-dev libxrandr-dev zlib1g-dev xvfb xauth libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev" MISSING="" for pkg in $PKGS; do if dpkg -s "$pkg" &>/dev/null; then diff --git a/.github/actions/smoke-tests/action.yml b/.github/actions/smoke-tests/action.yml index 64d6f74..c265647 100644 --- a/.github/actions/smoke-tests/action.yml +++ b/.github/actions/smoke-tests/action.yml @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause # name: 'Smoke Tests' -description: 'Run smoke tests on dvledtx and dvledtx-mtl binaries. Results written to $GITHUB_WORKSPACE/reports/' +description: 'Run smoke tests on dvledtx and dvledrx binaries. Results written to $GITHUB_WORKSPACE/reports/' runs: using: composite @@ -15,15 +15,19 @@ runs: REPORT_DIR="$GITHUB_WORKSPACE/reports" mkdir -p "$REPORT_DIR" - for BINARY in ./bins/dvledtx ./bins/dvledtx-mtl; do + for BINARY in ./bins/dvledtx ./bins/dvledtx-mtl ./bins/dvledrx; do NAME=$(basename "$BINARY") echo "===== Smoke Test: $NAME =====" OUTFILE="$REPORT_DIR/smoke-test-${NAME}.txt" # version check VERSION_OUTPUT=$(timeout 5s "$BINARY" -v 2>&1) - if ! echo "$VERSION_OUTPUT" | grep -qE "^dvledtx version [0-9]+\.[0-9]+\.[0-9]+"; then - echo "ERROR: $NAME -v output did not match expected format 'dvledtx version X.Y.Z'" + VERSION_PREFIX=dvledtx + if [ "$NAME" = "dvledrx" ]; then + VERSION_PREFIX=dvledrx + fi + if ! echo "$VERSION_OUTPUT" | grep -qE "^${VERSION_PREFIX} version [0-9]+\.[0-9]+\.[0-9]+"; then + echo "ERROR: $NAME -v output did not match expected format '${VERSION_PREFIX} version X.Y.Z'" echo "Got: $VERSION_OUTPUT" exit 1 fi diff --git a/.github/workflows/daily_build.yml b/.github/workflows/daily_build.yml index 96927ad..b58e7cc 100644 --- a/.github/workflows/daily_build.yml +++ b/.github/workflows/daily_build.yml @@ -75,8 +75,8 @@ jobs: afl-fuzz: name: Daily AFL Fuzz (${{ github.event.repository.default_branch }}) runs-on: ubuntu-latest - # 2h of fuzzing plus headroom for dependency install and the harness build. - timeout-minutes: 138 + # 4h of TX+RX fuzzing plus headroom for dependency install and builds. + timeout-minutes: 258 permissions: contents: read @@ -98,15 +98,17 @@ jobs: if: always() with: name: afl-fuzz-results-${{ github.event.repository.default_branch }}-${{ github.run_id }} - path: fuzz/findings/ + path: | + dvledtx/fuzz/findings/ + dvledrx/fuzz/findings/ retention-days: 14 libfuzzer: name: Daily libFuzzer (${{ github.event.repository.default_branch }}) runs-on: ubuntu-latest - # max-seconds applies per sanitizer mode (ASan + UBSan), so the fuzzing - # itself takes ~2h; the rest covers the two builds. - timeout-minutes: 138 + # max-seconds applies per target and sanitizer mode (TX+RX, ASan+UBSan), + # so fuzzing takes ~4h; the rest covers the four builds. + timeout-minutes: 258 permissions: contents: read @@ -129,8 +131,10 @@ jobs: with: name: libfuzzer-results-${{ github.event.repository.default_branch }}-${{ github.run_id }} path: | - fuzz/libfuzzer_corpus/ - fuzz/ubsan_corpus/ + dvledtx/fuzz/libfuzzer_corpus/ + dvledtx/fuzz/ubsan_corpus/ + dvledrx/fuzz/libfuzzer_corpus/ + dvledrx/fuzz/ubsan_corpus/ retention-days: 14 diff --git a/.gitignore b/.gitignore index 397b4a7..64d7c11 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ *.log +/build/ +/build-tests/ diff --git a/README.md b/README.md index 12bfc22..05f6b69 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/OpenVisualCloud/directview-led-software-toolkit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/OpenVisualCloud/directview-led-software-toolkit/actions/workflows/ci.yml) Coverity Scan Build Status [![Ubuntu](https://img.shields.io/badge/Ubuntu-22.04%20|%2024.04-orange.svg)](https://releases.ubuntu.com/jammy/) -[![MTL](https://img.shields.io/badge/MTL-v26.01+-green.svg)](https://github.com/OpenVisualCloud/Media-Transport-Library) +[![MTL](https://img.shields.io/badge/MTL-v26.09-green.svg)](https://github.com/OpenVisualCloud/Media-Transport-Library/releases/tag/v26.09) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/OpenVisualCloud/directview-led-software-toolkit/badge)](https://securityscorecards.dev/viewer/?uri=github.com/OpenVisualCloud/directview-led-software-toolkit) ## Table of Contents @@ -60,9 +60,10 @@ dvledtx reads a video source file (e.g., MP4), decodes it using FFmpeg, and tran ### Deployment Security Model -dvledtx follows a layered security model. The application validates and bounds every input it -owns — configuration parsing, log file destinations, decoder behaviour and resource limits — and -is covered by static analysis, fuzzing and dependency CVE tracking in CI. Transport-level +dvledtx and dvledrx follow a layered security model. The applications validate and bound their +inputs — including configuration parsing, log file destinations, decoder behaviour and resource +limits — and are covered by static analysis, AFL/libFuzzer fuzzing and dependency CVE tracking in +CI. Transport-level security is a separate layer: SMPTE ST 2110-20 and the PTP timing it depends on do not define authentication, encryption or integrity protection, which is a property of the standards common to all ST 2110 equipment. The deployment architecture supplies that layer. @@ -111,20 +112,22 @@ FFmpeg is an open source project licensed under LGPL and GPL. See https://www.ff > **Note:** This toolkit has been validated against **Ubuntu 22.04 LTS** but should work on Ubuntu 24.04 LTS and higher versions. - Ubuntu [22.04](https://releases.ubuntu.com/jammy/) or [24.04](https://releases.ubuntu.com/noble/) LTS -- [Media Transport Library (MTL) v26.01+](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md) +- Receiver build dependencies: `libx11-dev`, `libxext-dev`, `libxrandr-dev`, `libjson-c-dev`, and `zlib1g-dev`. +- Receiver X11/session unit tests use `xvfb-run`; install `xvfb` and `xauth` to run the full receiver test suite. +- [Media Transport Library (MTL) v26.09](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md) - Follow these steps - - [Install APT packages](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md#111-ubuntudebian) + - [Install APT packages](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md#111-ubuntudebian) - Clone Media-Transport-Library ``` git clone https://github.com/OpenVisualCloud/Media-Transport-Library.git cd Media-Transport-Library - git checkout 7bee24231c864d12da6db2c2ffbfd3986717af5a + git checkout 9762c314ed24bd628d3f808b95a94c3aa8525723 cd .. export mtl_source_code=${PWD}/Media-Transport-Library ``` - - [Build and install DPDK](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md#2-dpdk-build-and-install) - - [Build and install MTL](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md#3-build-media-transport-library-and-app) -- [FFmpeg 7.0 with MTL Plugin](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/ecosystem/ffmpeg_plugin/README.md#1-build) + - [Build and install DPDK](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md#2-dpdk-build-and-install) + - [Build and install MTL](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md#3-build-media-transport-library-and-app) + - [FFmpeg 7.0 with MTL Plugin](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/ecosystem/ffmpeg_plugin/README.md#1-build) - **Screen capture support (`input_mode: screen_capture`) requires FFmpeg's `x11grab` device.** It is auto-detected and compiled in by FFmpeg's `./configure` script, but only if these packages are installed *before* building FFmpeg: ```bash sudo apt-get install -y libx11-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev @@ -146,7 +149,7 @@ FFmpeg is an open source project licensed under LGPL and GPL. See https://www.ff vainfo | grep VAEntrypointVLD ``` If either prints nothing, hardware decode is unavailable and dvledtx silently falls back to CPU decode. See [Hardware Decode (VA-API)](#hardware-decode-va-api). - - **12-bit formats (`yuv422p12le`, `yuv444p12le`, `gbrp12le`) require both MTL and FFmpeg to be built from the pinned commit above.** The plugin sources are copied into FFmpeg's `libavdevice/` and compiled in, so rebuilding MTL alone is not enough — FFmpeg must be rebuilt against the same MTL commit or these formats are rejected at session setup. + - **12-bit formats (`yuv422p12le`, `yuv444p12le`, `gbrp12le`) require both MTL and FFmpeg to be built from the pinned v26.09 commit above.** The plugin sources are copied into FFmpeg's `libavdevice/` and compiled in, so rebuilding MTL alone is not enough — FFmpeg must be rebuilt against the same MTL commit or these formats are rejected at session setup. - At the pinned commit the `mtl_st20p` muxer exposes `p_port`/`r_port` plus `p2_port`..`p7_port` (and matching `p2_sip`..`p7_sip`), so up to 8 NICs work with the default (non-`ENABLE_MTL_TX`) build with no local patching. Older MTL revisions exposed only `p_port`/`r_port`, which capped `nic_count` at 2 for the FFmpeg TX path; the `ENABLE_MTL_TX` direct-pipeline build has always supported up to 8 NICs. ### Build Steps @@ -165,15 +168,15 @@ To build with MTL TX support enabled: bash scripts/build.sh -Denable_mtl_tx=true ``` -The built binary will be available at `build/dvledtx`. +The build produces `build/dvledtx/dvledtx` and `build/dvledrx/dvledrx`. ## Usage ### Binding Ethernet Controller to DPDK PMD and Hugepage Setup - Ensure VFIO group exists [follow](#vfio-group-setup) -- [DPDK PMD Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/run.md#3-dpdk-pmd-setup) -- [Hugepage Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/run.md#4-setup-hugepage) +- [DPDK PMD Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/run.md#3-dpdk-pmd-setup) +- [Hugepage Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/run.md#4-setup-hugepage) ### JSON Configuration @@ -204,7 +207,7 @@ dvledtx uses a JSON config file with the following sections: | | `payload_type` | (Optional) RTP payload type — defaults to `96` if not present | | | `crop` | Region to transmit: `x`, `y`, `w`, `h` in pixels | -#### Example: MP4 file input (`config/tx_fullhd_single_session.json`) +#### Example: MP4 file input (`dvledtx/config/tx_fullhd_single_session.json`) ```json { "log_file": "dvledtx.log", @@ -228,7 +231,7 @@ dvledtx uses a JSON config file with the following sections: } ``` -Multiple sessions can be defined in `tx_sessions` to transmit different crop regions of the same video simultaneously (see `config/tx_fullhd_multi_session.json`). +Multiple sessions can be defined in `tx_sessions` to transmit different crop regions of the same video simultaneously (see `dvledtx/config/tx_fullhd_multi_session.json`). #### PTP Timing @@ -422,7 +425,7 @@ Your physical keyboard and mouse now control the virtual display directly. > DISPLAY=:99 PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native google-chrome & > ``` -#### Example: screen-capture input (`config/tx_fullhd_screen_capture.json`) +#### Example: screen-capture input (`dvledtx/config/tx_fullhd_screen_capture.json`) ```json { @@ -445,7 +448,7 @@ Your physical keyboard and mouse now control the virtual display directly. } ``` -#### Example: multi-session screen capture (`config/tx_fullhd_screen_capture_multi_session.json`) +#### Example: multi-session screen capture (`dvledtx/config/tx_fullhd_screen_capture_multi_session.json`) Splits a 1920×1080 screen capture into 3 vertical strips, each transmitted as a separate ST2110 session: @@ -476,7 +479,7 @@ Splits a 1920×1080 screen capture into 3 vertical strips, each transmitted as a Run dvledtx as usual — `x11grab` will capture whatever is rendered on the display (desktop, windows, applications) and transmit it, exactly as it would for a physical display: ```bash -./build/dvledtx --config config/tx_fullhd_screen_capture.json +./build/dvledtx/dvledtx --config dvledtx/config/tx_fullhd_screen_capture.json ``` **Tear down** the virtual display when done: @@ -485,7 +488,7 @@ pkill -f "gnome-session --session=ubuntu" sudo pkill -f "Xorg :99" ``` -`interfaces[]` supports up to 8 NICs (MTL's port limit); each `tx_sessions[]` entry picks its NIC via `nic_index` (see `config/tx_fullhd_multi_nic.json` for an 8-NIC/8-session example). Using more than 2 NICs with the default FFmpeg TX path requires the `p2_port`..`p7_port` AVOptions described above, which are present at the pinned MTL commit. +`interfaces[]` supports up to 8 NICs (MTL's port limit); each `tx_sessions[]` entry picks its NIC via `nic_index` (see `dvledtx/config/tx_fullhd_multi_nic.json` for an 8-NIC/8-session example). Using more than 2 NICs with the default FFmpeg TX path requires the `p2_port`..`p7_port` AVOptions described above, which are present at the pinned MTL commit. ## Logging @@ -517,7 +520,7 @@ When `log_file` is set, log output is written to that file in addition to the co #### Using JSON Configuration (recommended) ```bash -./build/dvledtx --config config/tx_fullhd_single_session.json +./build/dvledtx/dvledtx --config dvledtx/config/tx_fullhd_single_session.json ``` ## Command-Line Options @@ -530,9 +533,9 @@ When `log_file` is set, log output is written to that file in addition to the co #### Show Version ```bash -./build/dvledtx --version +./build/dvledtx/dvledtx --version # or -./build/dvledtx -v +./build/dvledtx/dvledtx -v ``` ## Supported Formats @@ -618,7 +621,7 @@ sudo apt install libcmocka-dev pip install gcovr ``` -To build and run all unit tests with coverage: +To build and run both the transmitter and receiver unit tests with coverage: ```bash bash scripts/test.sh @@ -730,7 +733,7 @@ sudo pkill -9 -f dvledtx # Check MTL pkg-config file is available pkg-config --modversion mtl - # Expected: 26.01.0.REL (or higher) + # Expected: 26.09.0.REL # Check MTL header files exist ls /usr/local/include/mtl/mtl_api.h diff --git a/dvledrx/config/rx_2k_1session.json b/dvledrx/config/rx_2k_1session.json new file mode 100644 index 0000000..5829c27 --- /dev/null +++ b/dvledrx/config/rx_2k_1session.json @@ -0,0 +1,24 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { + "name": "0000:03:00.1", + "SIP": "192.168.50.65", + "DIP": "239.168.85.20" + } + ], + "video": { + "width": 2560, + "height": 1440, + "fps": 30, + "fmt": "yuv422p10le" + }, + "rx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 2560, "h": 1440 } + } + ] +} diff --git a/dvledrx/config/rx_4k_1session.json b/dvledrx/config/rx_4k_1session.json new file mode 100644 index 0000000..071014f --- /dev/null +++ b/dvledrx/config/rx_4k_1session.json @@ -0,0 +1,24 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { + "name": "0000:03:00.1", + "SIP": "192.168.50.65", + "DIP": "239.168.85.20" + } + ], + "video": { + "width": 3840, + "height": 2160, + "fps": 30, + "fmt": "gbrp12le" + }, + "rx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 3840, "h": 2160 } + } + ] +} diff --git a/dvledrx/config/rx_4k_2nic_2sessions_per_output.json b/dvledrx/config/rx_4k_2nic_2sessions_per_output.json new file mode 100644 index 0000000..7d3259a --- /dev/null +++ b/dvledrx/config/rx_4k_2nic_2sessions_per_output.json @@ -0,0 +1,33 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "tasklet_sleep": true, + "interfaces": [ + { "nic_index": 0, "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" }, + { "nic_index": 1, "name": "0000:03:00.1", "sip": "192.168.50.66", "dip": "239.168.85.21" } + ], + "video": { + "fps": 30, + "fmt": "yuv422p10le" + }, + "rx_sessions": [ + { "nic_index": 0, "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 1920, "h": 2160 } }, + { "nic_index": 1, "udp_port": 20002, "payload_type": 96, "crop": { "x": 1920, "y": 0, "w": 1920, "h": 2160 } } + ], + "displays": [ + { + "output": "DP-1", + "sessions": [ + { "session": 0, "x": 0, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 }, + { "session": 1, "x": 540, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 } + ] + }, + { + "output": "HDMI-2", + "sessions": [ + { "session": 0, "x": 0, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 }, + { "session": 1, "x": 540, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 } + ] + } + ] +} diff --git a/dvledrx/config/rx_8nic_8sessions.json b/dvledrx/config/rx_8nic_8sessions.json new file mode 100644 index 0000000..1ee6844 --- /dev/null +++ b/dvledrx/config/rx_8nic_8sessions.json @@ -0,0 +1,30 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { "nic_index": 0, "name": "0000:03:10.1", "sip": "192.168.50.60", "dip": "239.168.85.30" }, + { "nic_index": 1, "name": "0000:03:10.3", "sip": "192.168.50.61", "dip": "239.168.85.31" }, + { "nic_index": 2, "name": "0000:03:10.5", "sip": "192.168.50.62", "dip": "239.168.85.32" }, + { "nic_index": 3, "name": "0000:03:10.7", "sip": "192.168.50.63", "dip": "239.168.85.33" }, + { "nic_index": 4, "name": "0000:03:11.1", "sip": "192.168.50.64", "dip": "239.168.85.34" }, + { "nic_index": 5, "name": "0000:03:11.3", "sip": "192.168.50.65", "dip": "239.168.85.35" }, + { "nic_index": 6, "name": "0000:03:11.5", "sip": "192.168.50.66", "dip": "239.168.85.36" }, + { "nic_index": 7, "name": "0000:03:11.7", "sip": "192.168.50.67", "dip": "239.168.85.37" } + ], + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "fmt": "yuv422p10le" + }, + "rx_sessions": [ + { "nic_index": 0, "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 1, "udp_port": 20002, "payload_type": 96, "crop": { "x": 240, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 2, "udp_port": 20004, "payload_type": 96, "crop": { "x": 480, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 3, "udp_port": 20006, "payload_type": 96, "crop": { "x": 720, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 4, "udp_port": 20008, "payload_type": 96, "crop": { "x": 960, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 5, "udp_port": 20010, "payload_type": 96, "crop": { "x": 1200, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 6, "udp_port": 20012, "payload_type": 96, "crop": { "x": 1440, "y": 0, "w": 240, "h": 1080 } }, + { "nic_index": 7, "udp_port": 20014, "payload_type": 96, "crop": { "x": 1680, "y": 0, "w": 240, "h": 1080 } } + ] +} diff --git a/dvledrx/config/rx_crop_1session.json b/dvledrx/config/rx_crop_1session.json new file mode 100644 index 0000000..b346b62 --- /dev/null +++ b/dvledrx/config/rx_crop_1session.json @@ -0,0 +1,24 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { + "name": "0000:06:00.0", + "SIP": "192.168.50.65", + "DIP": "239.168.85.20" + } + ], + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "fmt": "yuv444p12le" + }, + "rx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 } + } + ] +} diff --git a/dvledrx/config/rx_crop_3sessions.json b/dvledrx/config/rx_crop_3sessions.json new file mode 100644 index 0000000..bd52d2e --- /dev/null +++ b/dvledrx/config/rx_crop_3sessions.json @@ -0,0 +1,34 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { + "name": "0000:06:00.0", + "SIP": "192.168.50.65", + "DIP": "239.168.85.20" + } + ], + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "fmt": "gbrp12le" + }, + "rx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 } + }, + { + "udp_port": 20002, + "payload_type": 96, + "crop": { "x": 640, "y": 0, "w": 640, "h": 1080 } + }, + { + "udp_port": 20004, + "payload_type": 96, + "crop": { "x": 1280, "y": 0, "w": 640, "h": 1080 } + } + ] +} diff --git a/dvledrx/config/rx_crop_3sessions_per_output.json b/dvledrx/config/rx_crop_3sessions_per_output.json new file mode 100644 index 0000000..11c7cf4 --- /dev/null +++ b/dvledrx/config/rx_crop_3sessions_per_output.json @@ -0,0 +1,34 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { "nic_index": 0, "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" }, + { "nic_index": 1, "name": "0000:03:00.1", "sip": "192.168.50.66", "dip": "239.168.85.21" } + ], + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "fmt": "yuv422p10le" + }, + "rx_sessions": [ + { "nic_index": 0, "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 960, "h": 1080 } }, + { "nic_index": 1, "udp_port": 20002, "payload_type": 96, "crop": { "x": 960, "y": 0, "w": 960, "h": 1080 } } + ], + "displays": [ + { + "output": "DP-1", + "sessions": [ + { "session": 0, "x": 0, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 }, + { "session": 1, "x": 540, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 } + ] + }, + { + "output": "HDMI-2", + "sessions": [ + { "session": 0, "x": 0, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 }, + { "session": 1, "x": 540, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 } + ] + } + ] +} diff --git a/dvledrx/config/rx_fullhd_1session.json b/dvledrx/config/rx_fullhd_1session.json new file mode 100644 index 0000000..0861346 --- /dev/null +++ b/dvledrx/config/rx_fullhd_1session.json @@ -0,0 +1,24 @@ +{ + "log_file": "RxApp.log", + "log_level": "info", + "interfaces": [ + { + "name": "0000:03:00.1", + "SIP": "192.168.50.65", + "DIP": "239.168.85.20" + } + ], + "video": { + "width": 960, + "height": 1080, + "fps": 30, + "fmt": "gbrp10le" + }, + "rx_sessions": [ + { + "udp_port": 20000, + "payload_type": 96, + "crop": { "x": 0, "y": 0, "w": 960, "h": 1080 } + } + ] +} diff --git a/dvledrx/fuzz/corpus/displays_mirrors.json b/dvledrx/fuzz/corpus/displays_mirrors.json new file mode 100644 index 0000000..a2ec897 --- /dev/null +++ b/dvledrx/fuzz/corpus/displays_mirrors.json @@ -0,0 +1 @@ +{"interfaces":[{"nic_index":0,"name":"0000:03:00.0","SIP":"192.168.50.65","DIP":"239.168.85.20"}],"video":{"width":1920,"height":1080,"fps":30,"fmt":"gbrp10le"},"rx_sessions":[{"nic_index":0,"udp_port":20000,"payload_type":96,"crop":{"x":0,"y":0,"w":1920,"h":1080}}],"displays":[{"output":"DP-1","sessions":[{"session":0,"x":0,"y":0,"w":960,"h":1080,"scale_w":960,"scale_h":1080}]},{"output":"HDMI-1","sessions":[{"session":0,"x":960,"y":0,"w":960,"h":1080,"scale_w":960,"scale_h":1080}]}]} diff --git a/dvledrx/fuzz/corpus/malformed.json b/dvledrx/fuzz/corpus/malformed.json new file mode 100644 index 0000000..9261d08 --- /dev/null +++ b/dvledrx/fuzz/corpus/malformed.json @@ -0,0 +1 @@ +{"interfaces":[{"nic_index":0,"name":"unterminated","SIP":,"DIP":"239.1.1.1"}],"rx_sessions":[} diff --git a/dvledrx/fuzz/corpus/multi_session.json b/dvledrx/fuzz/corpus/multi_session.json new file mode 100644 index 0000000..7819bff --- /dev/null +++ b/dvledrx/fuzz/corpus/multi_session.json @@ -0,0 +1 @@ +{"interfaces":[{"nic_index":0,"name":"0000:03:00.0","sip":"192.168.50.65","dip":"239.168.85.20"},{"nic_index":1,"name":"0000:03:00.1","sip":"192.168.50.66","dip":"239.168.85.21"}],"video":{"width":3840,"height":2160,"fps":60,"fmt":"yuv422p12le"},"rx_sessions":[{"nic_index":0,"udp_port":20000,"payload_type":96,"crop":{"x":0,"y":0,"w":1920,"h":2160}},{"nic_index":1,"udp_port":20002,"payload_type":96,"crop":{"x":1920,"y":0,"w":1920,"h":2160}}]} diff --git a/dvledrx/fuzz/corpus/valid_single.json b/dvledrx/fuzz/corpus/valid_single.json new file mode 100644 index 0000000..6ed9f7d --- /dev/null +++ b/dvledrx/fuzz/corpus/valid_single.json @@ -0,0 +1 @@ +{"interfaces":[{"nic_index":0,"name":"0000:03:00.0","sip":"192.168.50.65","dip":"239.168.85.20"}],"video":{"width":1920,"height":1080,"fps":30,"fmt":"yuv422p10le"},"rx_sessions":[{"nic_index":0,"udp_port":20000,"payload_type":96,"crop":{"x":0,"y":0,"w":1920,"h":1080}}]} diff --git a/dvledrx/fuzz/fuzz_config_reader.c b/dvledrx/fuzz/fuzz_config_reader.c new file mode 100644 index 0000000..032aa91 --- /dev/null +++ b/dvledrx/fuzz/fuzz_config_reader.c @@ -0,0 +1,55 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * AFL++ harness for the receiver JSON config parser. + * Build: afl-clang-fast -g -O1 -I../include fuzz_config_reader.c \ + * ../src/config_reader.c ../src/logger.c -pthread \ + * $(pkg-config --cflags --libs json-c zlib) -o fuzz_config_reader + * Run: afl-fuzz -i corpus/ -o findings/ -- ./fuzz_config_reader @@ + */ + +#include "fuzz_config_reader_common.h" + +#include +#include + +#ifdef __AFL_HAVE_MANUAL_CONTROL +__AFL_FUZZ_INIT(); +#endif + +static void fuzz_file(const char* filename) { + FILE* input = fopen(filename, "rb"); + if (!input) + return; + + uint8_t* data = malloc(RX_FUZZ_MAX_INPUT + 1u); + if (!data) { + fclose(input); + return; + } + + size_t size = fread(data, 1, RX_FUZZ_MAX_INPUT + 1u, input); + if (!ferror(input) && size <= RX_FUZZ_MAX_INPUT) + rx_fuzz_one_input(data, size); + + free(data); + fclose(input); +} + +int main(int argc, char** argv) { +#ifdef __AFL_HAVE_MANUAL_CONTROL + __AFL_INIT(); + unsigned char* data = __AFL_FUZZ_TESTCASE_BUF; + while (__AFL_LOOP(1000)) { + size_t size = __AFL_FUZZ_TESTCASE_LEN; + rx_fuzz_one_input(data, size); + } +#else + if (argc != 2) { + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + fuzz_file(argv[1]); +#endif + return 0; +} diff --git a/dvledrx/fuzz/fuzz_config_reader_common.h b/dvledrx/fuzz/fuzz_config_reader_common.h new file mode 100644 index 0000000..a776517 --- /dev/null +++ b/dvledrx/fuzz/fuzz_config_reader_common.h @@ -0,0 +1,51 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#pragma once + +#include "config_reader.h" +#include "logger.h" + +#include +#include +#include +#include +#include + +#define RX_FUZZ_MAX_INPUT (1u << 20) + +static inline void rx_fuzz_one_input(const uint8_t* data, size_t size) { + if ((!data && size != 0) || size > RX_FUZZ_MAX_INPUT) + return; + + char path[] = "/tmp/dvledrx_fuzz_XXXXXX"; + int fd = mkstemp(path); + if (fd < 0) + return; + + size_t written = 0; + while (written < size) { + ssize_t rc = write(fd, data + written, size - written); + if (rc < 0 && errno == EINTR) + continue; + if (rc <= 0) { + close(fd); + unlink(path); + return; + } + written += (size_t)rc; + } + + if (close(fd) != 0) { + unlink(path); + return; + } + + struct rx_config config; + logger_set_level(LOG_LEVEL_ERROR); + if (parse_rx_config(path, &config) == 0) + (void)validate_rx_config(&config); + + unlink(path); +} diff --git a/dvledrx/fuzz/fuzz_config_reader_libfuzzer.c b/dvledrx/fuzz/fuzz_config_reader_libfuzzer.c new file mode 100644 index 0000000..dcc84b8 --- /dev/null +++ b/dvledrx/fuzz/fuzz_config_reader_libfuzzer.c @@ -0,0 +1,12 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * libFuzzer harness for the receiver JSON config parser and validator. + */ + +#include "fuzz_config_reader_common.h" + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + rx_fuzz_one_input(data, size); + return 0; +} diff --git a/dvledrx/include/config_reader.h b/dvledrx/include/config_reader.h new file mode 100644 index 0000000..0c846d1 --- /dev/null +++ b/dvledrx/include/config_reader.h @@ -0,0 +1,94 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#pragma once +#include +#include +#include + +#define MAX_INTERFACES 8 /* MTL_PORT_MAX */ +#define MAX_RX_SESSIONS 16 +#define MAX_MIRRORS 4 /* extra outputs one session can be shown on */ + +/* Validation limits */ +#define MAX_VIDEO_DIM 3840 /* either orientation, e.g. 3840x2160 or 2160x3840 */ +#define MAX_VIDEO_PIXELS (3840 * 2160) /* 4K UHD */ +#define MAX_FPS 60 +#define NIC_LINK_MBPS 10000 /* warn above this per NIC */ + +/* Interface configuration (from JSON "interfaces" array) */ +struct interface_config { + int nic_index; /* id referenced by rx_sessions[].nic_index */ + char name[64]; /* PCI address, e.g. "0000:02:00.0" */ + char sip[32]; /* Source IP of this NIC */ + char dip[32]; /* Multicast destination IP */ +}; + +/* Crop region within the full video frame */ +struct crop_config { + int x; /* Horizontal offset in pixels */ + int y; /* Vertical offset in pixels */ + int w; /* Width in pixels */ + int h; /* Height in pixels */ +}; + +/* Additional placement of a session on another output (from "displays") */ +struct display_placement { + char output[32]; + struct crop_config window; + int scale_w; + int scale_h; +}; + +/* Per-stream RX session configuration (from JSON "rx_sessions" array) */ +struct rx_session_config { + int nic_index; /* interface this stream arrives on (default 0) */ + int udp_port; /* UDP port for this stream */ + int payload_type; /* RTP payload type */ + struct crop_config crop; /* Crop region within the full frame */ + char output[32]; /* XRandR output name, e.g. "HDMI-1" (empty = by index) */ + bool has_window; /* "window" given: fixed-size window instead of fullscreen */ + struct crop_config window; /* x/y relative to the output; w/h 0 = crop size */ + int scale_w; /* window/video size; overrides window.w/h (0 = unset) */ + int scale_h; + bool display_assigned; /* placed by the top-level "displays" section */ + struct display_placement mirrors[MAX_MIRRORS]; /* 2nd+ placements from "displays" */ + int num_mirrors; +}; + +/* Video parameters (from JSON "video" object) */ +struct video_config { + int width; /* Full source video width */ + int height; /* Full source video height */ + int fps; /* Frames per second */ + char fmt[32]; /* Pixel format, e.g. "yuv422p10le" */ +}; + +/* Top-level configuration parsed from the JSON file */ +struct rx_config { + struct interface_config interfaces[MAX_INTERFACES]; + int num_interfaces; + + struct video_config video; + + struct rx_session_config rx_sessions[MAX_RX_SESSIONS]; + int num_rx_sessions; + + char log_file[256]; /* Optional log file path (empty = console) */ + char log_level[16]; /* Optional log level: debug|info|warn|error */ + char display_mode[16]; /* "composite" (default) | "per_output" */ + bool tasklet_sleep; /* let the MTL scheduler sleep when idle (saves a busy core) */ +}; + +/* Parse JSON configuration file into rx_config. Returns 0 on success. */ +int parse_rx_config(const char* config_file, struct rx_config* config); + +/* Validate a parsed configuration. Returns 0 on success. */ +int validate_rx_config(const struct rx_config* config); + +/* Convenience: parse + validate in one call. */ +int load_and_validate_rx_config(const char* config_file, struct rx_config* config); + +/* Position in config->interfaces (== MTL port index) of nic_index, or -1. */ +int rx_config_port_for_nic(const struct rx_config* config, int nic_index); diff --git a/dvledrx/include/frame_converter.h b/dvledrx/include/frame_converter.h new file mode 100644 index 0000000..70d7649 --- /dev/null +++ b/dvledrx/include/frame_converter.h @@ -0,0 +1,25 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#pragma once +#include +#include + +// Forward declaration for st_frame +struct st_frame; + +// Common YUV to RGB conversion functions +void yuv_to_rgb(uint8_t y, uint8_t u, uint8_t v, uint8_t* r, uint8_t* g, uint8_t* b); + +// Get bit shift value for format +int get_format_bit_shift(int format); + +// Check if format uses YUV422 subsampling +bool is_yuv422_format(int format); + +// X11 display conversion function +int convert_frame_to_rgb(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t width, uint32_t height); + +// Nearest-neighbour resize fused with conversion (YUV422/YUV444/GBR planar only; -1 otherwise) +int convert_frame_to_rgb_scaled(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t dst_w, uint32_t dst_h); diff --git a/dvledrx/include/logger.h b/dvledrx/include/logger.h new file mode 100644 index 0000000..f0cfc0e --- /dev/null +++ b/dvledrx/include/logger.h @@ -0,0 +1,52 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#pragma once + +#include +#include +#include + +enum log_level { + LOG_LEVEL_DEBUG = 0, + LOG_LEVEL_INFO, + LOG_LEVEL_WARN, + LOG_LEVEL_ERROR, +}; + +void logger_set_level(enum log_level level); +enum log_level logger_get_level(void); + +/* Initialize log level from RXAPP_LOG_LEVEL if present. */ +void logger_init_from_env(void); + +#define LOGGER_MAX_BYTES (20u * 1024u * 1024u) /* rotate the log file at this size */ +#define LOGGER_MAX_ARCHIVES 10 /* keep .1.gz .. .10.gz */ + +/* Direct log output to a file. When file_path is NULL or empty, logs go + * to stderr (console). When the file reaches max_bytes it is gzip-compressed + * in the background to .1.gz (older archives shift up, the oldest beyond + * LOGGER_MAX_ARCHIVES is deleted) and a new file is started. Unsafe paths + * (see logger_path_is_safe) are refused and logging stays on the console. */ +void logger_set_file(const char* file_path, size_t max_bytes); + +/* True when an output file may be created at path: its directory and every + * parent are not world-writable (rejects /tmp, /var/tmp, /dev/shm) and are + * owned by root or the invoking user, and path is not a symlink or special + * file. On false, a reason is written to why. */ +bool logger_path_is_safe(const char* path, char* why, size_t why_len); + +/* Close the log file (if any) and revert to console output. */ +void logger_close(void); + +/* Parse string level (debug|info|warn|error). Returns true on success. */ +bool logger_parse_level(const char* level_str, enum log_level* out_level); + +void logger_vlog(enum log_level level, const char* tag, const char* fmt, va_list args); +void logger_log(enum log_level level, const char* tag, const char* fmt, ...); + +#define log_debug(tag, fmt, ...) logger_log(LOG_LEVEL_DEBUG, (tag), (fmt), ##__VA_ARGS__) +#define log_info(tag, fmt, ...) logger_log(LOG_LEVEL_INFO, (tag), (fmt), ##__VA_ARGS__) +#define log_warn(tag, fmt, ...) logger_log(LOG_LEVEL_WARN, (tag), (fmt), ##__VA_ARGS__) +#define log_error(tag, fmt, ...) logger_log(LOG_LEVEL_ERROR, (tag), (fmt), ##__VA_ARGS__) diff --git a/dvledrx/include/session_manager.h b/dvledrx/include/session_manager.h new file mode 100644 index 0000000..3045884 --- /dev/null +++ b/dvledrx/include/session_manager.h @@ -0,0 +1,98 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#pragma once + +#include +#include +#include +#include "mtl_api.h" +#include "st_pipeline_api.h" +#include "x11_handler.h" +#include "config_reader.h" + +/* Application state shared by main and the session manager. */ +struct rx_app_context { + mtl_handle mtl; + uint32_t width; /* full video size */ + uint32_t height; + enum st_fps fps; + enum st_frame_fmt fmt; + enum st20_fmt transport_fmt; + int st20p_sessions; + bool exit; + int test_time_s; /* 0 = run until stopped */ + char config_file[256]; + struct rx_config json_config; +}; + +/* ST20P RX session context */ +struct st20p_rx_ctx { + struct rx_app_context* app; + st20p_rx_handle handle; + pthread_t thread; + int idx; + size_t frame_size; + char session_name[32]; + + /* Frame statistics */ + int frames_received; + int frames_rx_window; /* frames since the last per-second stats line (atomic) */ + + /* Per-session display (used when st20p_count == 1) */ + struct x11_display_ctx display; + + /* Same frames mirrored onto further outputs (per_output mode) */ + struct x11_display_ctx mirror_display[MAX_MIRRORS]; + int num_mirror_displays; + + /* Composite display (used when st20p_count > 1, shared across sessions) */ + struct composite_x11_ctx* composite; + + /* Per-session crop region (from JSON config; valid when composite != NULL) */ + struct crop_config crop; + + /* Per-session network overrides (from JSON rx_sessions) */ + uint16_t session_udp_port; + uint8_t session_payload_type; + char port_name[MTL_PORT_MAX_LEN]; /* NIC this session receives on */ + uint8_t dip_addr[MTL_IP_ADDR_LEN]; /* multicast group on that NIC */ + + /* Decoupled display pipeline (single-session mode, composite == NULL). + * The RX thread hands the newest MTL frame to this dedicated thread, which + * converts/blits it and then returns it to MTL. A frame superseded before + * it is rendered is returned by the RX thread, so receive never stalls. */ + pthread_t disp_thread; + bool disp_thread_started; + pthread_mutex_t disp_lock; + pthread_cond_t disp_cond; + struct st_frame* disp_pending; /* newest MTL frame awaiting render (owned until put) */ + int disp_skipped; /* frames superseded before render (under disp_lock) */ +}; + +/* Session Manager Interface */ +typedef struct { + mtl_handle mtl; + struct st20p_rx_ctx* st20p_sessions; + int st20p_count; + bool running; + + /* Composite display (non-NULL in composite mode with st20p_count > 1) */ + struct composite_x11_ctx* composite; +} session_manager_t; + +/* Initialize MTL and create session manager */ +int session_manager_init(session_manager_t* manager, struct rx_app_context* app); + +/* Start all sessions */ +int session_manager_start(session_manager_t* manager, struct rx_app_context* app); + +/* Stop all sessions */ +void session_manager_stop(session_manager_t* manager); + +/* Cleanup session manager */ +void session_manager_cleanup(session_manager_t* manager); + +/* Check if sessions are running */ +bool session_manager_is_running(const session_manager_t* manager); diff --git a/dvledrx/include/x11_handler.h b/dvledrx/include/x11_handler.h new file mode 100644 index 0000000..035648e --- /dev/null +++ b/dvledrx/include/x11_handler.h @@ -0,0 +1,149 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#ifndef X11_HANDLER_H +#define X11_HANDLER_H + +#include +#include +#include +#include + +// Forward declarations +struct st_frame; +struct crop_config; + +enum { + X11_WALL_NONE = 0, /* ordinary decorated window: Escape does nothing */ + X11_WALL_FULLSCREEN, /* EWMH fullscreen on an output */ + X11_WALL_FIXED, /* override-redirect window at a fixed spot on an output */ +}; + +/* X11 Display context */ +struct x11_display_ctx { + void* display; /* Display* - X11 display connection */ + void* window; /* Window - X11 window handle */ + void* gc; /* GC - Graphics context */ + void* visual; /* Visual* - X11 visual info */ + void* ximage; /* XImage* - X11 image buffer */ + int screen; /* Screen number */ + uint32_t width; /* Window width */ + uint32_t height; /* Window height */ + uint32_t frame_width; /* Latest incoming frame width */ + uint32_t frame_height; /* Latest incoming frame height */ + int depth; /* Color depth */ + unsigned long colormap; /* Colormap of a 32-bit ARGB window, else 0 */ + char* image_data; /* Image buffer */ + char* frame_scratch; /* Frame-sized intermediate BGRA buffer */ + size_t frame_scratch_cap; /* Allocated bytes in frame_scratch */ + void* shminfo; /* XShmSegmentInfo* when image_data is MIT-SHM backed, else NULL */ + bool use_shm; /* Try MIT-SHM when (re)allocating the image buffer */ + uint32_t frame_counter; + char output_name[32]; /* XRandR output the window is on, or "(window)" */ + int out_x, out_y; /* window position relative to that output */ + int wall_kind; /* X11_WALL_* : how the window is shown in wall mode */ + int mon_x, mon_y, mon_w, mon_h; /* output geometry in root coordinates */ + uint32_t home_w, home_h; /* wall-mode window size */ + int monitor_idx; /* Xinerama index of the output, -1 if none */ + bool windowed; /* currently a normal decorated window (Escape toggles) */ + bool wall_pending; /* waiting for the WM to release the window before going back */ + long long pending_ms; /* when wall_pending was set (CLOCK_MONOTONIC) */ + bool initialized; /* Initialization status */ + pthread_mutex_t xlock; /* Protect Display* from concurrent RX/main-thread access */ +}; + +/* + * Composite display context - single X11 window showing N side-by-side strips. + * All N session threads write their strip, hit a barrier, then session 0 flushes. + */ +struct composite_x11_ctx { + void* display; /* Display* */ + void* window; /* Window (full composite width) */ + void* gc; /* GC */ + void* visual; /* Visual* */ + void* ximage; /* XImage* (full_width × height) - native composite buffer */ + int screen; + uint32_t full_width; /* native composite width (sum of crop regions) */ + uint32_t strip_width; /* pixel width of one session's strip (legacy) */ + uint32_t height; /* native composite height */ + int depth; + char* image_data; /* full_width * height * 4 bytes (BGRA) - native composite buffer */ + /* Per-frame barrier for the session threads; composite_abort() releases it for shutdown. */ + pthread_mutex_t bar_lock; + pthread_cond_t bar_cond; + int bar_count; /* threads waiting in the current round */ + unsigned bar_round; + bool bar_aborted; + pthread_mutex_t xlock; /* protect X11 Display* from concurrent access */ + int num_sessions; + bool initialized; + + /* Current on-screen window size. Starts equal to full_width/height, but + * diverges when the window manager resizes/fullscreens the window (e.g. + * user hits the fullscreen shortcut). When it differs, the native + * composite buffer is nearest-neighbour scaled into scaled_image_data / + * scaled_ximage before XPutImage, so the composite window fills the + * screen the same way a single-session window does. */ + uint32_t win_width; + uint32_t win_height; + void* scaled_ximage; /* XImage* (win_width × win_height); NULL when win size == native size */ + char* scaled_image_data; /* backing store for scaled_ximage */ +}; + +/* Per-session X11 initialization and cleanup. + * fullscreen_on_output=false: normal window of width x height. + * fullscreen_on_output=true : borderless fullscreen window on the XRandR output + * named output_name (e.g. "HDMI-1"), or the session_idx-th active monitor + * when output_name is NULL/empty. + * window_rect (only with fullscreen_on_output): non-NULL gives a fixed borderless + * window of window_rect->w x h at (x, y) relative to the output, instead of fullscreen. */ +int init_x11_display(struct x11_display_ctx* display, uint32_t width, uint32_t height, + int session_idx, const char* output_name, bool fullscreen_on_output, + const struct crop_config* window_rect); +void cleanup_x11_display(struct x11_display_ctx* display); + +/* Per-session frame display */ +void display_frame_x11(struct x11_display_ctx* display, struct st_frame* frame); + +/* Show src's last rendered image in dst when both windows are the same size. + * Returns false (nothing drawn) when sizes differ. */ +bool display_copy_x11(struct x11_display_ctx* dst, struct x11_display_ctx* src); + +/* Per-session event processing. Escape in any window toggles every wall window + * between wall mode and a normal window with title bar (minimize/maximize/close). */ +void process_x11_events(struct x11_display_ctx* display); + +/* True once the user clicked a window's close button. */ +bool x11_close_requested(void); + +/* Composite display initialization / cleanup. + * strip_width is the total display width (not per-strip). */ +int init_composite_x11_display(struct composite_x11_ctx* comp, + uint32_t strip_width, uint32_t height, + int num_sessions); +void cleanup_composite_x11_display(struct composite_x11_ctx* comp); + +/* Release every session thread waiting at the composite barrier, now and from now on. */ +void composite_abort(struct composite_x11_ctx* comp); + +/* + * Write one strip to the composite buffer, wait at barrier, then + * session 0 calls XPutImage to flush the complete frame. + */ +void composite_write_strip(struct composite_x11_ctx* comp, + struct st_frame* frame, int session_idx); + +/* + * Write a frame into the composite buffer at an arbitrary crop position. + * The frame dimensions are crop->w × crop->h and it is placed at (crop->x, crop->y). + * After all sessions have called this, the serial-thread flushes the display. + */ +void composite_write_crop(struct composite_x11_ctx* comp, + struct st_frame* frame, + const struct crop_config* crop); + +/* Event processing for the composite window */ +void process_composite_x11_events(struct composite_x11_ctx* comp); + +#endif /* X11_HANDLER_H */ diff --git a/dvledrx/meson.build b/dvledrx/meson.build new file mode 100644 index 0000000..6c9b3b7 --- /dev/null +++ b/dvledrx/meson.build @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2022 Intel Corporation + +# This subdirectory defines the receiver application and its nested tests. +rx_cc = meson.get_compiler('c') + +rx_x11_dep = dependency('x11', required: true) +rx_xext_dep = dependency('xext', required: true) +rx_xrandr_dep = dependency('xrandr', required: true) +rx_json_c_dep = dependency('json-c', required: true) +rx_zlib_dep = dependency('zlib', required: true) + +rx_c_args = [ + '-DALLOW_EXPERIMENTAL_API', + '-DDVLEDRX_VERSION="' + meson.project_version() + '"', + '-D_FORTIFY_SOURCE=2', + '-O3', +] +foreach arg : ['-march=native', '-funroll-loops'] + if rx_cc.has_argument(arg) + rx_c_args += [arg] + endif +endforeach +rx_mtl_include_args = ['-I/usr/local/include/mtl'] +rx_deps = [mtl_dep, m_dep, pthread_dep, rx_x11_dep, rx_xext_dep, + rx_xrandr_dep, rx_json_c_dep, rx_zlib_dep] + +executable('dvledrx', + sources: [ + 'src/main.c', + 'src/frame_converter.c', + 'src/x11_handler.c', + 'src/config_reader.c', + 'src/session_manager.c', + 'src/logger.c', + ], + include_directories: include_directories('include'), + dependencies: rx_deps, + c_args: rx_c_args + rx_mtl_include_args, + link_args: ['-s'], + install: false +) + +subdir('tests') diff --git a/dvledrx/src/config_reader.c b/dvledrx/src/config_reader.c new file mode 100644 index 0000000..04ff92d --- /dev/null +++ b/dvledrx/src/config_reader.c @@ -0,0 +1,427 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include "config_reader.h" +#include "logger.h" +#include +#include +#include +#include +#include + +#define LOGGER_TAG "config" + +/* ------------------------------------------------------------------ */ +/* Helper: safely copy a JSON string value into a fixed-size buffer. */ +/* ------------------------------------------------------------------ */ +static void json_str_copy(json_object* obj, const char* key, + char* dst, size_t dst_size) { + json_object* val = NULL; + if (json_object_object_get_ex(obj, key, &val) && + json_object_is_type(val, json_type_string)) { + strncpy(dst, json_object_get_string(val), dst_size - 1); + dst[dst_size - 1] = '\0'; + } +} + +/* Helper: get an integer from a JSON object (returns fallback on miss). */ +static int json_int_get(json_object* obj, const char* key, int fallback) { + json_object* val = NULL; + if (json_object_object_get_ex(obj, key, &val) && + json_object_is_type(val, json_type_int)) { + return json_object_get_int(val); + } + return fallback; +} + +/* ------------------------------------------------------------------ */ +/* parse_rx_config – read the JSON file and populate struct rx_config. */ +/* ------------------------------------------------------------------ */ +int parse_rx_config(const char* config_file, struct rx_config* config) { + if (!config_file || !config) return -1; + + memset(config, 0, sizeof(*config)); + + json_object* root = json_object_from_file(config_file); + if (!root) { + log_error(LOGGER_TAG, "Cannot parse JSON config file %s", config_file); + return -1; + } + + /* ---------- interfaces ---------- */ + json_object* ifaces = NULL; + if (json_object_object_get_ex(root, "interfaces", &ifaces) && + json_object_is_type(ifaces, json_type_array)) { + int n = json_object_array_length(ifaces); + if (n > MAX_INTERFACES) { + log_warn(LOGGER_TAG, "%d interfaces configured, only the first %d are used", + n, MAX_INTERFACES); + n = MAX_INTERFACES; + } + config->num_interfaces = n; + for (int i = 0; i < n; i++) { + json_object* item = json_object_array_get_idx(ifaces, i); + config->interfaces[i].nic_index = json_int_get(item, "nic_index", i); + json_str_copy(item, "name", config->interfaces[i].name, + sizeof(config->interfaces[i].name)); + /* Accept both the RX ("SIP") and TX ("sip") spellings. */ + json_str_copy(item, "SIP", config->interfaces[i].sip, + sizeof(config->interfaces[i].sip)); + if (config->interfaces[i].sip[0] == '\0') + json_str_copy(item, "sip", config->interfaces[i].sip, + sizeof(config->interfaces[i].sip)); + json_str_copy(item, "DIP", config->interfaces[i].dip, + sizeof(config->interfaces[i].dip)); + if (config->interfaces[i].dip[0] == '\0') + json_str_copy(item, "dip", config->interfaces[i].dip, + sizeof(config->interfaces[i].dip)); + } + } + + /* ---------- video ---------- */ + json_object* video = NULL; + if (json_object_object_get_ex(root, "video", &video) && + json_object_is_type(video, json_type_object)) { + config->video.width = json_int_get(video, "width", 0); + config->video.height = json_int_get(video, "height", 0); + config->video.fps = json_int_get(video, "fps", 30); + json_str_copy(video, "fmt", config->video.fmt, sizeof(config->video.fmt)); + } + + /* ---------- log_file ---------- */ + json_str_copy(root, "log_file", config->log_file, sizeof(config->log_file)); + json_str_copy(root, "log_level", config->log_level, sizeof(config->log_level)); + json_str_copy(root, "display_mode", config->display_mode, sizeof(config->display_mode)); + + json_object* sleep_obj = NULL; + if (json_object_object_get_ex(root, "tasklet_sleep", &sleep_obj)) + config->tasklet_sleep = json_object_get_boolean(sleep_obj); + + /* ---------- rx_sessions ---------- */ + json_object* sessions = NULL; + if (json_object_object_get_ex(root, "rx_sessions", &sessions) && + json_object_is_type(sessions, json_type_array)) { + int n = json_object_array_length(sessions); + if (n > MAX_RX_SESSIONS) { + log_warn(LOGGER_TAG, "%d rx_sessions configured, only the first %d are used", + n, MAX_RX_SESSIONS); + n = MAX_RX_SESSIONS; + } + config->num_rx_sessions = n; + for (int i = 0; i < n; i++) { + json_object* s = json_object_array_get_idx(sessions, i); + config->rx_sessions[i].nic_index = json_int_get(s, "nic_index", 0); + config->rx_sessions[i].udp_port = json_int_get(s, "udp_port", 20000); + config->rx_sessions[i].payload_type = json_int_get(s, "payload_type", 96); + json_str_copy(s, "output", config->rx_sessions[i].output, + sizeof(config->rx_sessions[i].output)); + + json_object* scale = NULL; + if (json_object_object_get_ex(s, "scale", &scale) && + json_object_is_type(scale, json_type_object)) { + config->rx_sessions[i].scale_w = json_int_get(scale, "w", 0); + config->rx_sessions[i].scale_h = json_int_get(scale, "h", 0); + } + + json_object* crop = NULL; + if (json_object_object_get_ex(s, "crop", &crop) && + json_object_is_type(crop, json_type_object)) { + config->rx_sessions[i].crop.x = json_int_get(crop, "x", 0); + config->rx_sessions[i].crop.y = json_int_get(crop, "y", 0); + config->rx_sessions[i].crop.w = json_int_get(crop, "w", 0); + config->rx_sessions[i].crop.h = json_int_get(crop, "h", 0); + } + + json_object* win = NULL; + if (json_object_object_get_ex(s, "window", &win) && + json_object_is_type(win, json_type_object)) { + config->rx_sessions[i].has_window = true; + config->rx_sessions[i].window.x = json_int_get(win, "x", 0); + config->rx_sessions[i].window.y = json_int_get(win, "y", 0); + config->rx_sessions[i].window.w = json_int_get(win, "w", 0); + config->rx_sessions[i].window.h = json_int_get(win, "h", 0); + } + } + } + + /* ---------- displays: output -> sessions placed on it ---------- */ + json_object* displays = NULL; + if (json_object_object_get_ex(root, "displays", &displays) && + json_object_is_type(displays, json_type_array)) { + if (config->display_mode[0] == '\0') + snprintf(config->display_mode, sizeof(config->display_mode), "per_output"); + int nd = json_object_array_length(displays); + for (int d = 0; d < nd; d++) { + json_object* disp = json_object_array_get_idx(displays, d); + char output[32] = ""; + json_str_copy(disp, "output", output, sizeof(output)); + + json_object* list = NULL; + if (!json_object_object_get_ex(disp, "sessions", &list) || + !json_object_is_type(list, json_type_array)) + continue; + int ns = json_object_array_length(list); + for (int k = 0; k < ns; k++) { + json_object* e = json_object_array_get_idx(list, k); + int idx = json_int_get(e, "session", -1); + if (idx < 0 || idx >= config->num_rx_sessions) { + log_error(LOGGER_TAG, "displays[%d] (%s) references unknown session %d", + d, output, idx); + json_object_put(root); + return -1; + } + struct rx_session_config* s = &config->rx_sessions[idx]; + if (s->display_assigned) { + if (s->num_mirrors >= MAX_MIRRORS) { + log_error(LOGGER_TAG, "session %d is placed on more than %d displays", + idx, MAX_MIRRORS + 1); + json_object_put(root); + return -1; + } + struct display_placement* m = &s->mirrors[s->num_mirrors++]; + snprintf(m->output, sizeof(m->output), "%s", output); + m->window.x = json_int_get(e, "x", 0); + m->window.y = json_int_get(e, "y", 0); + m->window.w = json_int_get(e, "w", 0); + m->window.h = json_int_get(e, "h", 0); + m->scale_w = json_int_get(e, "scale_w", 0); + m->scale_h = json_int_get(e, "scale_h", 0); + continue; + } + s->display_assigned = true; + snprintf(s->output, sizeof(s->output), "%s", output); + s->has_window = true; + s->window.x = json_int_get(e, "x", 0); + s->window.y = json_int_get(e, "y", 0); + s->window.w = json_int_get(e, "w", 0); + s->window.h = json_int_get(e, "h", 0); + s->scale_w = json_int_get(e, "scale_w", 0); + s->scale_h = json_int_get(e, "scale_h", 0); + } + } + } + + json_object_put(root); /* free parsed tree */ + + /* video width/height are optional: default to the bounding box of all crops. */ + if (config->video.width <= 0 || config->video.height <= 0) { + int max_x = 0, max_y = 0; + for (int i = 0; i < config->num_rx_sessions; i++) { + const struct crop_config* c = &config->rx_sessions[i].crop; + if (c->x + c->w > max_x) max_x = c->x + c->w; + if (c->y + c->h > max_y) max_y = c->y + c->h; + } + if (config->video.width <= 0) config->video.width = max_x > 0 ? max_x : 1920; + if (config->video.height <= 0) config->video.height = max_y > 0 ? max_y : 1080; + log_info(LOGGER_TAG, "video size not set, using %dx%d from session crops", + config->video.width, config->video.height); + } + if (config->video.fps == 0) config->video.fps = 30; + return 0; +} + +/* Supported "fmt" names -> bits per pixel on the wire (ST 2110-20). */ +static int fmt_bits_per_pixel(const char* fmt) { + static const struct { const char* name; int bpp; } fmts[] = { + {"yuv422p10le", 20}, {"yuv422p12le", 24}, + {"yuv444p10le", 30}, {"yuv444p12le", 36}, + {"gbrp10le", 30}, {"gbrp12le", 36}, + {"yuv420", 12}, + }; + if (!fmt || fmt[0] == '\0') return 20; /* default yuv422p10le */ + for (size_t i = 0; i < sizeof(fmts) / sizeof(fmts[0]); i++) + if (strcmp(fmt, fmts[i].name) == 0) return fmts[i].bpp; + return -1; +} + +static int check_window(const char* what, int idx, const char* output, + const struct crop_config* win, int scale_w, int scale_h) { + if (output && output[0] == '\0') { + log_error(LOGGER_TAG, "%s for session %d has no \"output\" name", what, idx); + return -1; + } + if (win->x < 0 || win->y < 0 || win->w < 0 || win->h < 0 || + win->x > MAX_VIDEO_DIM || win->y > MAX_VIDEO_DIM || + win->w > MAX_VIDEO_DIM || win->h > MAX_VIDEO_DIM) { + log_error(LOGGER_TAG, "%s for session %d has invalid window %d,%d %dx%d (0..%d)", + what, idx, win->x, win->y, win->w, win->h, MAX_VIDEO_DIM); + return -1; + } + if (scale_w < 0 || scale_h < 0 || scale_w > MAX_VIDEO_DIM || scale_h > MAX_VIDEO_DIM || + (scale_w > 0) != (scale_h > 0)) { + log_error(LOGGER_TAG, "%s for session %d has invalid scale %dx%d " + "(set both, each 1..%d)", what, idx, scale_w, scale_h, MAX_VIDEO_DIM); + return -1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* validate_rx_config */ +/* ------------------------------------------------------------------ */ +int validate_rx_config(const struct rx_config* config) { + if (!config) return -1; + + if (config->num_interfaces < 1) { + log_error(LOGGER_TAG, "At least one interface is required"); + return -1; + } + for (int i = 0; i < config->num_interfaces; i++) { + const struct interface_config* ifc = &config->interfaces[i]; + struct in_addr addr; + if (ifc->name[0] == '\0') { + log_error(LOGGER_TAG, "interfaces[%d] has no PCI name", i); + return -1; + } + if (inet_pton(AF_INET, ifc->sip, &addr) != 1) { + log_error(LOGGER_TAG, "interfaces[%d] (%s) has invalid source IP '%s'", + i, ifc->name, ifc->sip); + return -1; + } + if (ifc->dip[0] == '\0') { + log_error(LOGGER_TAG, "interfaces[%d] (%s) has no destination IP (dip)", i, ifc->name); + return -1; + } + if (inet_pton(AF_INET, ifc->dip, &addr) != 1) { + log_error(LOGGER_TAG, "interfaces[%d] (%s) has invalid destination IP '%s'", + i, ifc->name, ifc->dip); + return -1; + } + for (int j = 0; j < i; j++) { + if (config->interfaces[j].nic_index == ifc->nic_index) { + log_error(LOGGER_TAG, "interfaces[%d] and [%d] share nic_index %d", + j, i, ifc->nic_index); + return -1; + } + if (strcmp(config->interfaces[j].name, ifc->name) == 0) { + log_error(LOGGER_TAG, "interfaces[%d] and [%d] use the same NIC %s", + j, i, ifc->name); + return -1; + } + } + } + const struct video_config* v = &config->video; + if (v->width <= 0 || v->height <= 0 || v->width > MAX_VIDEO_DIM || v->height > MAX_VIDEO_DIM || + (long)v->width * v->height > MAX_VIDEO_PIXELS) { + log_error(LOGGER_TAG, "Invalid video size %dx%d (max 4K: 3840x2160 or 2160x3840)", + v->width, v->height); + return -1; + } + static const int fps_ok[] = {24, 25, 29, 30, 50, 59, 60}; + bool fps_valid = false; + for (size_t i = 0; i < sizeof(fps_ok) / sizeof(fps_ok[0]); i++) + fps_valid |= (v->fps == fps_ok[i]); + if (!fps_valid) { + log_error(LOGGER_TAG, "Invalid fps %d (use 24|25|29|30|50|59|60, max %d)", v->fps, MAX_FPS); + return -1; + } + int bpp = fmt_bits_per_pixel(v->fmt); + if (bpp < 0) { + log_error(LOGGER_TAG, "Unknown fmt '%s' (use yuv422p10le|yuv422p12le|yuv444p10le|" + "yuv444p12le|gbrp10le|gbrp12le|yuv420)", v->fmt); + return -1; + } + if (config->num_rx_sessions < 1) { + log_error(LOGGER_TAG, "At least one rx_session is required"); + return -1; + } + if (config->display_mode[0] != '\0' && + strcmp(config->display_mode, "composite") != 0 && + strcmp(config->display_mode, "per_output") != 0) { + log_error(LOGGER_TAG, "Invalid display_mode '%s' (use composite|per_output)", + config->display_mode); + return -1; + } + double nic_mbps[MAX_INTERFACES] = {0}; + for (int i = 0; i < config->num_rx_sessions; i++) { + const struct rx_session_config* s = &config->rx_sessions[i]; + if (s->crop.w <= 0 || s->crop.h <= 0 || s->crop.x < 0 || s->crop.y < 0) { + log_error(LOGGER_TAG, "rx_session[%d] has invalid crop %d,%d %dx%d", + i, s->crop.x, s->crop.y, s->crop.w, s->crop.h); + return -1; + } + if (s->crop.x + s->crop.w > v->width || s->crop.y + s->crop.h > v->height) { + log_error(LOGGER_TAG, "rx_session[%d] crop %d,%d %dx%d is outside the %dx%d video", + i, s->crop.x, s->crop.y, s->crop.w, s->crop.h, v->width, v->height); + return -1; + } + if (s->udp_port < 1 || s->udp_port > 65535) { + log_error(LOGGER_TAG, "rx_session[%d] has invalid udp_port %d", i, s->udp_port); + return -1; + } + if (s->payload_type < 96 || s->payload_type > 127) { + log_error(LOGGER_TAG, "rx_session[%d] payload_type %d not in dynamic range 96..127", + i, s->payload_type); + return -1; + } + for (int j = 0; j < i; j++) { + if (config->rx_sessions[j].nic_index == s->nic_index && + config->rx_sessions[j].udp_port == s->udp_port) { + log_error(LOGGER_TAG, "rx_session[%d] and [%d] both use nic_index %d udp_port %d", + j, i, s->nic_index, s->udp_port); + return -1; + } + } + int port = rx_config_port_for_nic(config, s->nic_index); + if (port < 0) { + log_error(LOGGER_TAG, "rx_session[%d] uses nic_index %d, which no interface has", + i, s->nic_index); + return -1; + } + /* +5% for RTP/UDP/IP/Ethernet headers (matches measured MTL rx rate). */ + nic_mbps[port] += (double)s->crop.w * s->crop.h * bpp * v->fps * 1.05 / 1e6; + + const struct crop_config no_window = {0, 0, 0, 0}; + if (check_window("window", i, s->display_assigned ? s->output : NULL, + s->has_window ? &s->window : &no_window, s->scale_w, s->scale_h) < 0) + return -1; + for (int m = 0; m < s->num_mirrors; m++) { + const struct display_placement* pl = &s->mirrors[m]; + if (check_window("mirror", i, pl->output, &pl->window, pl->scale_w, pl->scale_h) < 0) + return -1; + } + } + for (int p = 0; p < config->num_interfaces; p++) { + if (nic_mbps[p] > NIC_LINK_MBPS) { + log_warn(LOGGER_TAG, "interfaces[%d] (%s) needs ~%.0f Mb/s, above a %d Mb/s link", + p, config->interfaces[p].name, nic_mbps[p], NIC_LINK_MBPS); + } + } + if (config->log_file[0] != '\0') { + char why[512]; + if (!logger_path_is_safe(config->log_file, why, sizeof(why))) { + log_error(LOGGER_TAG, "log_file '%s' rejected: %s", config->log_file, why); + return -1; + } + } + + log_info(LOGGER_TAG, "Configuration validation passed (%d session%s, %d interface%s)", + config->num_rx_sessions, + config->num_rx_sessions > 1 ? "s - multi-display" : " - single display", + config->num_interfaces, config->num_interfaces > 1 ? "s" : ""); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* load_and_validate_rx_config */ +/* ------------------------------------------------------------------ */ +int load_and_validate_rx_config(const char* config_file, struct rx_config* config) { + if (!config_file || !config || config_file[0] == '\0') return -1; + + if (parse_rx_config(config_file, config) != 0 || + validate_rx_config(config) != 0) { + log_error(LOGGER_TAG, "Config file %s is invalid", config_file); + return -1; + } + + log_info(LOGGER_TAG, "Loaded configuration from: %s", config_file); + return 0; +} + +int rx_config_port_for_nic(const struct rx_config* config, int nic_index) { + for (int i = 0; i < config->num_interfaces; i++) { + if (config->interfaces[i].nic_index == nic_index) return i; + } + return -1; +} \ No newline at end of file diff --git a/dvledrx/src/frame_converter.c b/dvledrx/src/frame_converter.c new file mode 100644 index 0000000..a616e7d --- /dev/null +++ b/dvledrx/src/frame_converter.c @@ -0,0 +1,373 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include "frame_converter.h" +#include "logger.h" +#include +#include +#include +#include + +// Include MTL headers for st_frame definition +#include "st_pipeline_api.h" + +#define LOGGER_TAG "converter" + +/* + * YUV/RGB conversion of received ST2110 frames into the BGRA buffers shown by + * the X11 display, with proper bit-depth scaling and chroma subsampling. + */ + +static inline uint8_t clamp_u8(int val) { + return (val < 0) ? 0 : (val > 255) ? 255 : val; +} + +void yuv_to_rgb(uint8_t y, uint8_t u, uint8_t v, uint8_t* r, uint8_t* g, uint8_t* b) { + // ITU-R BT.601 conversion (studio range) + int c = y - 16; + int d = u - 128; + int e = v - 128; + *r = clamp_u8((298 * c + 409 * e + 128) >> 8); + *g = clamp_u8((298 * c - 100 * d - 208 * e + 128) >> 8); + *b = clamp_u8((298 * c + 516 * d + 128) >> 8); +} + +/* --- BT.601 conversion lookup tables ------------------------------------- + * The per-pixel YUV->RGB math reduces to a few multiplies that depend only + * on the 8-bit Y/U/V inputs. Precomputing them as tables turns the inner + * loop into pure lookups + adds (no multiplies, no function call), which is + * the difference between keeping up with 30fps at 2560x1440 and not. + * + * r = 298*(Y-16) + 409*(V-128) + 128 + * g = 298*(Y-16) - 100*(U-128) - 208*(V-128) + 128 + * b = 298*(Y-16) + 516*(U-128) + 128 + */ +static int g_yc_tab[256]; /* 298*(Y-16) + 128 (rounding folded in) */ +static int g_rv_tab[256]; /* 409*(V-128) */ +static int g_gu_tab[256]; /* -100*(U-128) */ +static int g_gv_tab[256]; /* -208*(V-128) */ +static int g_bu_tab[256]; /* 516*(U-128) */ +static pthread_once_t g_yuv_tabs_once = PTHREAD_ONCE_INIT; + +static void init_yuv_tables_impl(void) { + for (int i = 0; i < 256; i++) { + g_yc_tab[i] = 298 * (i - 16) + 128; + g_rv_tab[i] = 409 * (i - 128); + g_gu_tab[i] = -100 * (i - 128); + g_gv_tab[i] = -208 * (i - 128); + g_bu_tab[i] = 516 * (i - 128); + } +} + +static void init_yuv_tables(void) { + pthread_once(&g_yuv_tabs_once, init_yuv_tables_impl); +} + +int get_format_bit_shift(int format) { + switch (format) { + case ST_FRAME_FMT_YUV422PLANAR12LE: + case ST_FRAME_FMT_YUV444PLANAR12LE: + case ST_FRAME_FMT_GBRPLANAR12LE: + return 4; // 12-bit formats + case ST_FRAME_FMT_YUV422PLANAR10LE: + case ST_FRAME_FMT_YUV444PLANAR10LE: + case ST_FRAME_FMT_GBRPLANAR10LE: + return 2; // 10-bit formats + case ST_FRAME_FMT_YUV420PLANAR8: case ST_FRAME_FMT_YUV420CUSTOM8: default: + return 0; // 8-bit formats + } +} + +bool is_yuv422_format(int format) { + return (format == ST_FRAME_FMT_YUV422PLANAR10LE || format == ST_FRAME_FMT_YUV422PLANAR12LE); +} + +/* Convert frame to RGB format for X11 display */ +int convert_frame_to_rgb(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t width, uint32_t height) { + if (!frame || !rgb_buffer) { + log_error(LOGGER_TAG, "convert_frame_to_rgb - NULL pointer (frame=%p, buffer=%p)", frame, rgb_buffer); + return -1; + } + + if (!frame->addr[0]) { + log_error(LOGGER_TAG, "convert_frame_to_rgb - No frame data (addr[0]=NULL)"); + return -1; + } + + if (frame->width != width || frame->height != height) { + log_error(LOGGER_TAG, "convert_frame_to_rgb - Size mismatch (frame=%dx%d, target=%dx%d)", + frame->width, frame->height, width, height); + return -1; + } + + switch (frame->fmt) { + case ST_FRAME_FMT_BGRA: { + /* MTL already converted to BGRA — direct memcpy, no pixel conversion needed */ + memcpy(rgb_buffer, frame->addr[0], width * height * 4); + break; + } + + case ST_FRAME_FMT_YUV422PLANAR10LE: + case ST_FRAME_FMT_YUV422PLANAR12LE: { + uint16_t* src_y = (uint16_t*)frame->addr[0]; + uint16_t* src_u = (uint16_t*)frame->addr[1]; + uint16_t* src_v = (uint16_t*)frame->addr[2]; + int bit_shift = (frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE) ? 4 : 2; + + // Use linesize from frame (in bytes), convert to stride in pixels for 16-bit data + size_t y_stride = frame->linesize[0] / 2; // Y linesize in 16-bit pixels + size_t u_stride = frame->linesize[1] / 2; // U linesize in 16-bit pixels + size_t v_stride = frame->linesize[2] / 2; // V linesize in 16-bit pixels + + init_yuv_tables(); + + for (uint32_t y = 0; y < height; y++) { + const uint16_t* yrow = src_y + (size_t)y * y_stride; + const uint16_t* urow = src_u + (size_t)y * u_stride; + const uint16_t* vrow = src_v + (size_t)y * v_stride; + uint8_t* drow = rgb_buffer + (size_t)y * width * 4; + + for (uint32_t x = 0; x < width; x++) { + // YUV422: horizontal subsampling for U,V + uint8_t y_val = (yrow[x] >> bit_shift) & 0xFF; + uint8_t u_val = (urow[x >> 1] >> bit_shift) & 0xFF; + uint8_t v_val = (vrow[x >> 1] >> bit_shift) & 0xFF; + + int yc = g_yc_tab[y_val]; + int r = (yc + g_rv_tab[v_val]) >> 8; + int g = (yc + g_gu_tab[u_val] + g_gv_tab[v_val]) >> 8; + int b = (yc + g_bu_tab[u_val]) >> 8; + + uint8_t* px = drow + (size_t)x * 4; + px[0] = clamp_u8(b); // Blue + px[1] = clamp_u8(g); // Green + px[2] = clamp_u8(r); // Red + px[3] = 255; // Alpha + } + } + break; + } + + case ST_FRAME_FMT_YUV444PLANAR10LE: + case ST_FRAME_FMT_YUV444PLANAR12LE: { + uint16_t* src_y = (uint16_t*)frame->addr[0]; + uint16_t* src_u = (uint16_t*)frame->addr[1]; + uint16_t* src_v = (uint16_t*)frame->addr[2]; + int bit_shift = (frame->fmt == ST_FRAME_FMT_YUV444PLANAR12LE) ? 4 : 2; + + size_t y_stride = frame->linesize[0] / 2; + size_t u_stride = frame->linesize[1] / 2; + size_t v_stride = frame->linesize[2] / 2; + + init_yuv_tables(); + + for (uint32_t y = 0; y < height; y++) { + const uint16_t* yrow = src_y + (size_t)y * y_stride; + const uint16_t* urow = src_u + (size_t)y * u_stride; + const uint16_t* vrow = src_v + (size_t)y * v_stride; + uint8_t* drow = rgb_buffer + (size_t)y * width * 4; + + for (uint32_t x = 0; x < width; x++) { + // YUV444: no subsampling, each pixel has its own U,V + uint8_t y_val = (yrow[x] >> bit_shift) & 0xFF; + uint8_t u_val = (urow[x] >> bit_shift) & 0xFF; + uint8_t v_val = (vrow[x] >> bit_shift) & 0xFF; + + int yc = g_yc_tab[y_val]; + int r = (yc + g_rv_tab[v_val]) >> 8; + int g = (yc + g_gu_tab[u_val] + g_gv_tab[v_val]) >> 8; + int b = (yc + g_bu_tab[u_val]) >> 8; + + uint8_t* px = drow + (size_t)x * 4; + px[0] = clamp_u8(b); + px[1] = clamp_u8(g); + px[2] = clamp_u8(r); + px[3] = 255; + } + } + break; + } + + case ST_FRAME_FMT_GBRPLANAR10LE: + case ST_FRAME_FMT_GBRPLANAR12LE: { + /* GBR planar 10-bit: plane 0 = Green, plane 1 = Blue, plane 2 = Red */ + uint16_t* src_g = (uint16_t*)frame->addr[0]; + uint16_t* src_b = (uint16_t*)frame->addr[1]; + uint16_t* src_r = (uint16_t*)frame->addr[2]; + int bit_shift = (frame->fmt == ST_FRAME_FMT_GBRPLANAR12LE) ? 4 : 2; + + size_t g_stride = frame->linesize[0] / 2; + size_t b_stride = frame->linesize[1] / 2; + size_t r_stride = frame->linesize[2] / 2; + + for (uint32_t y = 0; y < height; y++) { + const uint16_t* grow = src_g + (size_t)y * g_stride; + const uint16_t* brow = src_b + (size_t)y * b_stride; + const uint16_t* rrow = src_r + (size_t)y * r_stride; + uint8_t* drow = rgb_buffer + (size_t)y * width * 4; + + for (uint32_t x = 0; x < width; x++) { + uint8_t* px = drow + (size_t)x * 4; + px[0] = (brow[x] >> bit_shift) & 0xFF; + px[1] = (grow[x] >> bit_shift) & 0xFF; + px[2] = (rrow[x] >> bit_shift) & 0xFF; + px[3] = 255; + } + } + break; + } + + case ST_FRAME_FMT_YUV420PLANAR8: { + uint8_t* src_y = (uint8_t*)frame->addr[0]; + uint8_t* src_u = (uint8_t*)frame->addr[1]; + uint8_t* src_v = (uint8_t*)frame->addr[2]; + + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + uint8_t y_val = src_y[y * width + x]; + uint8_t u_val = src_u[(y/2) * (width/2) + (x/2)]; + uint8_t v_val = src_v[(y/2) * (width/2) + (x/2)]; + + uint8_t r, g, b; + yuv_to_rgb(y_val, u_val, v_val, &r, &g, &b); + + uint32_t idx = (y * width + x) * 4; + rgb_buffer[idx + 0] = b; // Blue + rgb_buffer[idx + 1] = g; // Green + rgb_buffer[idx + 2] = r; // Red + rgb_buffer[idx + 3] = 255; // Alpha + } + } + break; + } + + case ST_FRAME_FMT_YUV420CUSTOM8: { + /* I420 planar in single buffer: Y(w*h) + U(w/2*h/2) + V(w/2*h/2) + * Optimised: process 2 horizontal pixels at a time sharing U/V, + * inline BT.601 conversion with pre-computed chroma terms. */ + uint8_t* base = (uint8_t*)frame->addr[0]; + uint32_t y_plane_sz = width * height; + uint32_t uv_plane_sz = (width / 2) * (height / 2); + const uint8_t* src_y = base; + const uint8_t* src_u = base + y_plane_sz; + const uint8_t* src_v = base + y_plane_sz + uv_plane_sz; + uint32_t half_w = width / 2; + + for (uint32_t row = 0; row < height; row++) { + const uint8_t* y_row = src_y + row * width; + const uint8_t* u_row = src_u + (row / 2) * half_w; + const uint8_t* v_row = src_v + (row / 2) * half_w; + uint8_t* dst = rgb_buffer + row * width * 4; + + for (uint32_t cx = 0; cx < half_w; cx++) { + int d = (int)u_row[cx] - 128; + int e = (int)v_row[cx] - 128; + /* Pre-compute chroma contributions (shared by 2 pixels) */ + int cr_r = 409 * e + 128; + int cr_g = -100 * d - 208 * e + 128; + int cr_b = 516 * d + 128; + + for (int k = 0; k < 2; k++) { + int c = (int)y_row[cx * 2 + k] - 16; + int c298 = 298 * c; + int rv = (c298 + cr_r) >> 8; + int gv = (c298 + cr_g) >> 8; + int bv = (c298 + cr_b) >> 8; + if (rv < 0) rv = 0; else if (rv > 255) rv = 255; + if (gv < 0) gv = 0; else if (gv > 255) gv = 255; + if (bv < 0) bv = 0; else if (bv > 255) bv = 255; + dst[0] = (uint8_t)bv; + dst[1] = (uint8_t)gv; + dst[2] = (uint8_t)rv; + dst[3] = 255; + dst += 4; + } + } + } + break; + } + + default: + log_warn(LOGGER_TAG, "Unsupported format %d for X11 display, filling with test pattern", frame->fmt); + // Fill with test pattern + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + uint32_t idx = (y * width + x) * 4; + rgb_buffer[idx + 0] = (x + y) % 256; // Blue gradient + rgb_buffer[idx + 1] = x % 256; // Green gradient + rgb_buffer[idx + 2] = y % 256; // Red gradient + rgb_buffer[idx + 3] = 255; // Alpha + } + } + break; + } + + return 0; +} + +/* Same sampling as scale_bgra_nearest() in x11_handler.c, but only the sampled + * source pixels are converted, so cost scales with the output size. */ +int convert_frame_to_rgb_scaled(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t dst_w, uint32_t dst_h) { + if (!frame || !rgb_buffer || !frame->addr[0] || !frame->addr[1] || !frame->addr[2] || + !dst_w || !dst_h || !frame->width || !frame->height) + return -1; + + bool yuv422 = frame->fmt == ST_FRAME_FMT_YUV422PLANAR10LE || frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE; + bool yuv444 = frame->fmt == ST_FRAME_FMT_YUV444PLANAR10LE || frame->fmt == ST_FRAME_FMT_YUV444PLANAR12LE; + bool gbr = frame->fmt == ST_FRAME_FMT_GBRPLANAR10LE || frame->fmt == ST_FRAME_FMT_GBRPLANAR12LE; + if (!yuv422 && !yuv444 && !gbr) return -1; + + uint32_t src_w = frame->width, src_h = frame->height; + uint32_t* sx_tab = malloc((size_t)dst_w * sizeof(*sx_tab)); + if (!sx_tab) return -1; + for (uint32_t x = 0; x < dst_w; x++) + sx_tab[x] = (uint32_t)(((uint64_t)x * src_w) / dst_w); + + const uint16_t* p0 = (const uint16_t*)frame->addr[0]; + const uint16_t* p1 = (const uint16_t*)frame->addr[1]; + const uint16_t* p2 = (const uint16_t*)frame->addr[2]; + size_t s0 = frame->linesize[0] / 2, s1 = frame->linesize[1] / 2, s2 = frame->linesize[2] / 2; + int bit_shift = get_format_bit_shift(frame->fmt); + int uv_shift = yuv422 ? 1 : 0; + + init_yuv_tables(); + + for (uint32_t y = 0; y < dst_h; y++) { + uint32_t sy = (uint32_t)(((uint64_t)y * src_h) / dst_h); + const uint16_t* r0 = p0 + (size_t)sy * s0; + const uint16_t* r1 = p1 + (size_t)sy * s1; + const uint16_t* r2 = p2 + (size_t)sy * s2; + uint8_t* drow = rgb_buffer + (size_t)y * dst_w * 4; + + if (gbr) { + for (uint32_t x = 0; x < dst_w; x++) { + uint32_t sx = sx_tab[x]; + uint8_t* px = drow + (size_t)x * 4; + px[0] = (r1[sx] >> bit_shift) & 0xFF; + px[1] = (r0[sx] >> bit_shift) & 0xFF; + px[2] = (r2[sx] >> bit_shift) & 0xFF; + px[3] = 255; + } + continue; + } + + for (uint32_t x = 0; x < dst_w; x++) { + uint32_t sx = sx_tab[x]; + uint32_t cx = sx >> uv_shift; + uint8_t y_val = (r0[sx] >> bit_shift) & 0xFF; + uint8_t u_val = (r1[cx] >> bit_shift) & 0xFF; + uint8_t v_val = (r2[cx] >> bit_shift) & 0xFF; + + int yc = g_yc_tab[y_val]; + uint8_t* px = drow + (size_t)x * 4; + px[0] = clamp_u8((yc + g_bu_tab[u_val]) >> 8); + px[1] = clamp_u8((yc + g_gu_tab[u_val] + g_gv_tab[v_val]) >> 8); + px[2] = clamp_u8((yc + g_rv_tab[v_val]) >> 8); + px[3] = 255; + } + } + + free(sx_tab); + return 0; +} diff --git a/dvledrx/src/logger.c b/dvledrx/src/logger.c new file mode 100644 index 0000000..3284b2e --- /dev/null +++ b/dvledrx/src/logger.c @@ -0,0 +1,401 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include "logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static _Atomic enum log_level g_log_level = LOG_LEVEL_INFO; +static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER; +static FILE* g_log_file = NULL; /* NULL → use stderr */ +static char g_log_file_path[512] = {0}; +static size_t g_log_max_bytes = 0; + +/* Background gzip of a rotated log; g_gz_running is protected by g_log_lock. */ +static pthread_t g_gz_thread; +static bool g_gz_running = false; +static atomic_bool g_gz_done = false; +static time_t g_gz_last_retry = 0; /* protected by g_log_lock */ + +static const char *level_color[] = { + [LOG_LEVEL_ERROR] = "\033[31m", /* red */ + [LOG_LEVEL_WARN] = "\033[33m", /* yellow */ + [LOG_LEVEL_INFO] = "\033[32m", /* green */ + [LOG_LEVEL_DEBUG] = "\033[36m", /* cyan */ +}; +static const char *color_reset = "\033[0m"; + +static const char* level_to_text(enum log_level level) { + switch (level) { + case LOG_LEVEL_DEBUG: + return "DEBUG"; + case LOG_LEVEL_INFO: + return "INFO"; + case LOG_LEVEL_WARN: + return "WARN"; + case LOG_LEVEL_ERROR: + return "ERROR"; + default: + return "INFO"; + } +} + +static void to_lower_copy(const char* src, char* dst, size_t dst_size) { + if (!src || !dst || dst_size == 0) return; + + size_t i = 0; + for (; src[i] != '\0' && i < dst_size - 1; i++) { + dst[i] = (char)tolower((unsigned char)src[i]); + } + dst[i] = '\0'; +} + +bool logger_parse_level(const char* level_str, enum log_level* out_level) { + if (!level_str || !out_level) return false; + + char norm[16]; + to_lower_copy(level_str, norm, sizeof(norm)); + + if (strcmp(norm, "debug") == 0) { + *out_level = LOG_LEVEL_DEBUG; + return true; + } + if (strcmp(norm, "info") == 0) { + *out_level = LOG_LEVEL_INFO; + return true; + } + if (strcmp(norm, "warn") == 0 || strcmp(norm, "warning") == 0) { + *out_level = LOG_LEVEL_WARN; + return true; + } + if (strcmp(norm, "error") == 0) { + *out_level = LOG_LEVEL_ERROR; + return true; + } + + return false; +} + +void logger_set_level(enum log_level level) { atomic_store(&g_log_level, level); } + +enum log_level logger_get_level(void) { return atomic_load(&g_log_level); } + +bool logger_path_is_safe(const char* path, char* why, size_t why_len) { + char dir[PATH_MAX], real[PATH_MAX]; + + if (!path || path[0] == '\0') { + snprintf(why, why_len, "empty path"); + return false; + } + const char* slash = strrchr(path, '/'); + if (slash && slash[1] == '\0') { + snprintf(why, why_len, "'%s' is a directory", path); + return false; + } + if (!slash) + snprintf(dir, sizeof(dir), "."); + else if (slash == path) + snprintf(dir, sizeof(dir), "/"); + else + snprintf(dir, sizeof(dir), "%.*s", (int)(slash - path), path); + + if (!realpath(dir, real)) { + snprintf(why, why_len, "directory '%s' does not exist", dir); + return false; + } + + const char* sudo_uid_env = getenv("SUDO_UID"); + uid_t sudo_uid = sudo_uid_env ? (uid_t)strtoul(sudo_uid_env, NULL, 10) : (uid_t)-1; + for (;;) { + struct stat st; + if (stat(real, &st) != 0) { + snprintf(why, why_len, "cannot stat '%s'", real); + return false; + } + if (st.st_mode & S_IWOTH) { + snprintf(why, why_len, "'%s' is world-writable (e.g. /tmp); use a private directory", real); + return false; + } + if (st.st_uid != 0 && st.st_uid != geteuid() && st.st_uid != sudo_uid) { + snprintf(why, why_len, "'%s' is owned by another user (uid %u)", real, (unsigned)st.st_uid); + return false; + } + if (strcmp(real, "/") == 0) break; + char* s = strrchr(real, '/'); + if (s == real) s[1] = '\0'; + else *s = '\0'; + } + + struct stat lst; + if (lstat(path, &lst) == 0 && !S_ISREG(lst.st_mode)) { + snprintf(why, why_len, "'%s' is not a regular file (symlink or device)", path); + return false; + } + return true; +} + +/* Append-open without following symlinks; only regular files, mode 0640. */ +static FILE* open_log_file(const char* path) { + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0640); + if (fd < 0) return NULL; + struct stat st; + if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) { + close(fd); + return NULL; + } + FILE* f = fdopen(fd, "a"); + if (!f) close(fd); + return f; +} + +static int gzip_file(const char* src, const char* dst) { + char tmp[PATH_MAX + 8]; + snprintf(tmp, sizeof(tmp), "%s.tmp", dst); + + int in = open(src, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (in < 0) return -1; + unlink(tmp); + int out = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0640); + if (out < 0) { + close(in); + return -1; + } + gzFile gz = gzdopen(out, "wb6"); + if (!gz) { + close(out); + close(in); + unlink(tmp); + return -1; + } + + int rc = 0; + char buf[64 * 1024]; + ssize_t n; + while ((n = read(in, buf, sizeof(buf))) > 0) { + if (gzwrite(gz, buf, (unsigned)n) != (int)n) { + rc = -1; + break; + } + } + if (n < 0) rc = -1; + if (gzclose(gz) != Z_OK) rc = -1; /* also closes out */ + close(in); + if (rc == 0 && rename(tmp, dst) != 0) rc = -1; + if (rc != 0) unlink(tmp); + return rc; +} + +/* gzip .rotating to a temp archive first; only on success shift .N.gz up by + * one (dropping the oldest) and install it as .1.gz. On failure nothing is + * shifted or deleted and .rotating stays for a later retry. */ +static void* gz_worker(void* arg) { + char* base = arg; + char a[PATH_MAX], b[PATH_MAX], pending[PATH_MAX], fresh[PATH_MAX]; + + snprintf(pending, sizeof(pending), "%s.rotating", base); + snprintf(fresh, sizeof(fresh), "%s.new.gz", base); + if (gzip_file(pending, fresh) != 0) { + fprintf(stderr, "logger: cannot compress %s, will retry\n", pending); + unlink(fresh); + free(base); + atomic_store(&g_gz_done, true); + return NULL; + } + + snprintf(a, sizeof(a), "%s.%d.gz", base, LOGGER_MAX_ARCHIVES); + unlink(a); + for (int i = LOGGER_MAX_ARCHIVES - 1; i >= 1; i--) { + snprintf(a, sizeof(a), "%s.%d.gz", base, i); + snprintf(b, sizeof(b), "%s.%d.gz", base, i + 1); + if (rename(a, b) != 0 && errno != ENOENT) + fprintf(stderr, "logger: cannot rename %s: %s\n", a, strerror(errno)); + } + snprintf(a, sizeof(a), "%s.1.gz", base); + if (rename(fresh, a) == 0) + unlink(pending); + else + fprintf(stderr, "logger: cannot install %s: %s\n", a, strerror(errno)); + + free(base); + atomic_store(&g_gz_done, true); + return NULL; +} + +/* Must be called with g_log_lock held. */ +static void start_archiver(void) { + char* base = strdup(g_log_file_path); + if (!base) return; + atomic_store(&g_gz_done, false); + if (pthread_create(&g_gz_thread, NULL, gz_worker, base) == 0) { + g_gz_running = true; + } else { + gz_worker(base); /* no thread: compress inline */ + } +} + +/* Must be called with g_log_lock held. */ +static void join_archiver(void) { + if (g_gz_running) { + pthread_join(g_gz_thread, NULL); + g_gz_running = false; + } +} + +void logger_set_file(const char* file_path, size_t max_bytes) { + pthread_mutex_lock(&g_log_lock); + + /* Close any previously opened log file */ + if (g_log_file) { + fclose(g_log_file); + g_log_file = NULL; + } + g_log_file_path[0] = '\0'; + g_log_max_bytes = 0; + + if (file_path && file_path[0] != '\0') { + char why[PATH_MAX + 96]; + if (!logger_path_is_safe(file_path, why, sizeof(why))) { + fprintf(stderr, "logger: refusing log file '%s': %s; using console\n", file_path, why); + } else if (strlen(file_path) >= sizeof(g_log_file_path)) { + fprintf(stderr, "logger: log file path too long, using console\n"); + } else if ((g_log_file = open_log_file(file_path)) != NULL) { + snprintf(g_log_file_path, sizeof(g_log_file_path), "%s", file_path); + g_log_max_bytes = max_bytes; + /* Archive a rotation interrupted by a previous crash. */ + char pending[PATH_MAX]; + struct stat st; + snprintf(pending, sizeof(pending), "%s.rotating", file_path); + if (!g_gz_running && lstat(pending, &st) == 0 && S_ISREG(st.st_mode)) start_archiver(); + } else { + /* Cannot open — fall through to stderr; print a notice. */ + fprintf(stderr, "logger: cannot open log file '%s': %s; using console\n", + file_path, strerror(errno)); + } + } + + pthread_mutex_unlock(&g_log_lock); +} + +void logger_close(void) { + pthread_mutex_lock(&g_log_lock); + if (g_log_file) { + fclose(g_log_file); + g_log_file = NULL; + } + g_log_file_path[0] = '\0'; + join_archiver(); + pthread_mutex_unlock(&g_log_lock); +} + +void logger_init_from_env(void) { + const char* level = getenv("RXAPP_LOG_LEVEL"); + enum log_level parsed; + + if (!level) return; + + if (logger_parse_level(level, &parsed)) { + logger_set_level(parsed); + } +} + +/* Rotate when the file exceeds the limit: rename it aside, reopen a fresh file + * immediately, and gzip the old one on a background thread so callers (RX and + * display threads) never wait for compression. Must be called with g_log_lock held. */ +static void check_and_rotate(void) { + if (!g_log_file || g_log_max_bytes == 0) return; + + long pos = ftell(g_log_file); + if (pos < 0) { + /* ftell failed – try stat as fallback */ + struct stat st; + if (stat(g_log_file_path, &st) == 0) + pos = (long)st.st_size; + else + return; + } + if ((size_t)pos < g_log_max_bytes) return; + + if (g_gz_running) { + if (!atomic_load(&g_gz_done)) return; /* previous archive still compressing: keep appending */ + join_archiver(); + } + + char pending[PATH_MAX]; + snprintf(pending, sizeof(pending), "%s.rotating", g_log_file_path); + struct stat pst; + if (lstat(pending, &pst) == 0) { + /* An earlier archive failed to compress: never overwrite it. Keep appending + * and retry compressing it at most every 10 s. */ + time_t now = time(NULL); + if (now - g_gz_last_retry >= 10) { + g_gz_last_retry = now; + start_archiver(); + } + return; + } + fclose(g_log_file); + g_log_file = NULL; + bool renamed = rename(g_log_file_path, pending) == 0; + g_log_file = open_log_file(g_log_file_path); + if (!renamed) { + if (g_log_file) + fprintf(g_log_file, "--- log rotation failed: %s ---\n", strerror(errno)); + return; + } + if (g_log_file) { + fprintf(g_log_file, "--- log rotated: previous %ld bytes -> %s.1.gz (keeping %d archives) ---\n", + pos, g_log_file_path, LOGGER_MAX_ARCHIVES); + fflush(g_log_file); + } + start_archiver(); +} + +void logger_vlog(enum log_level level, const char* tag, const char* fmt, va_list args) { + if (level < atomic_load(&g_log_level)) return; + + time_t now = time(NULL); + struct tm tm_now; + char time_buf[20]; + + localtime_r(&now, &tm_now); + strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", &tm_now); + + pthread_mutex_lock(&g_log_lock); + + FILE* out = g_log_file ? g_log_file : stderr; + + if (g_log_file) check_and_rotate(); + + /* Re-read out in case check_and_rotate changed g_log_file */ + out = g_log_file ? g_log_file : stderr; + + bool use_color = (!g_log_file && level <= LOG_LEVEL_ERROR); + + if (use_color) fputs(level_color[level], out); + fprintf(out, "%s [%s] [%s] ", time_buf, level_to_text(level), tag ? tag : "app"); + vfprintf(out, fmt, args); + if (use_color) fputs(color_reset, out); + fputc('\n', out); + fflush(out); + pthread_mutex_unlock(&g_log_lock); +} + +void logger_log(enum log_level level, const char* tag, const char* fmt, ...) { + va_list args; + va_start(args, fmt); + logger_vlog(level, tag, fmt, args); + va_end(args); +} diff --git a/dvledrx/src/main.c b/dvledrx/src/main.c new file mode 100644 index 0000000..80dafb4 --- /dev/null +++ b/dvledrx/src/main.c @@ -0,0 +1,282 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "mtl_api.h" +#include "st_pipeline_api.h" +#include "frame_converter.h" +#include "x11_handler.h" +#include "config_reader.h" +#include "session_manager.h" +#include "logger.h" + +#define LOGGER_TAG "main" + +/* Overridden by the build system with the meson project version. */ +#ifndef DVLEDRX_VERSION +#define DVLEDRX_VERSION "1.0.0" +#endif + +/* Map a plain integer fps value to the correct MTL enum. + * MTL uses enum st_fps for RTP timestamp spacing — passing a raw + * integer (e.g. 30) instead of ST_FPS_P30 (==8) makes the receiver + * expect the wrong timestamp cadence and triggers rv_slot_by_tmstamp + * errors on every packet. */ +static enum st_fps int_fps_to_st_fps(int fps) { + switch (fps) { + case 120: return ST_FPS_P120; + case 100: return ST_FPS_P100; + case 60: return ST_FPS_P60; + case 59: return ST_FPS_P59_94; + case 50: return ST_FPS_P50; + case 30: return ST_FPS_P30; + case 29: return ST_FPS_P29_97; + case 25: return ST_FPS_P25; + case 24: return ST_FPS_P24; + default: + log_warn(LOGGER_TAG, "Unknown FPS %d, defaulting to ST_FPS_P25", fps); + return ST_FPS_P25; + } +} + +/* Map a format name string (as used by FFmpeg -pix_fmt) to the MTL enum. + * Accepts both the ffmpeg short name and the MTL enum name so the + * value in the config feels natural. */ +static enum st_frame_fmt str_fmt_to_st_fmt(const char* s) { + if (!s) return ST_FRAME_FMT_YUV422PLANAR10LE; + /* 10-bit formats */ + if (strcmp(s, "yuv422p10le") == 0) return ST_FRAME_FMT_YUV422PLANAR10LE; + if (strcmp(s, "yuv444p10le") == 0) return ST_FRAME_FMT_YUV444PLANAR10LE; + if (strcmp(s, "gbrp10le") == 0) return ST_FRAME_FMT_GBRPLANAR10LE; + /* 12-bit formats */ + if (strcmp(s, "yuv422p12le") == 0) return ST_FRAME_FMT_YUV422PLANAR12LE; + if (strcmp(s, "yuv444p12le") == 0) return ST_FRAME_FMT_YUV444PLANAR12LE; + if (strcmp(s, "gbrp12le") == 0) return ST_FRAME_FMT_GBRPLANAR12LE; + /* 8-bit / special formats */ + if (strcmp(s, "yuv420") == 0) return ST_FRAME_FMT_YUV420CUSTOM8; + /* Also accept raw integer for backward compatibility */ + char* end; + long v = strtol(s, &end, 10); + if (*end == '\0') return (enum st_frame_fmt)v; + log_warn(LOGGER_TAG, "Unknown format '%s', defaulting to yuv422p10le", s); + return ST_FRAME_FMT_YUV422PLANAR10LE; +} + +/* Map an output frame format to ST2110-20 transport format. */ +static enum st20_fmt st_fmt_to_transport_fmt(enum st_frame_fmt frame_fmt) { + switch (frame_fmt) { + case ST_FRAME_FMT_YUV422PLANAR10LE: + return ST20_FMT_YUV_422_10BIT; + case ST_FRAME_FMT_YUV422PLANAR12LE: + return ST20_FMT_YUV_422_12BIT; + case ST_FRAME_FMT_YUV444PLANAR10LE: + return ST20_FMT_YUV_444_10BIT; + case ST_FRAME_FMT_YUV444PLANAR12LE: + return ST20_FMT_YUV_444_12BIT; + case ST_FRAME_FMT_GBRPLANAR10LE: + return ST20_FMT_RGB_10BIT; + case ST_FRAME_FMT_GBRPLANAR12LE: + return ST20_FMT_RGB_12BIT; + case ST_FRAME_FMT_YUV420PLANAR8: + case ST_FRAME_FMT_YUV420CUSTOM8: + return ST20_FMT_YUV_420_8BIT; + default: + log_warn(LOGGER_TAG, "Unknown frame format %d, defaulting transport to YUV_422_10BIT", frame_fmt); + return ST20_FMT_YUV_422_10BIT; + } +} + +/* Set by the signal handler; logged and acted on by the main loop (logging is not async-signal-safe). */ +static volatile sig_atomic_t g_stop_signal = 0; + +static void rx_app_sig_handler(int sig) { + g_stop_signal = sig; +} + +static const char* signal_name(int sig) { + return sig == SIGINT ? "SIGINT(Ctrl+C)" : sig == SIGTERM ? "SIGTERM" : + sig == SIGQUIT ? "SIGQUIT(Ctrl+\\)" : "UNKNOWN"; +} + +static void print_help(const char* prog_name) { + log_info(LOGGER_TAG, "Usage: %s --config [options]", prog_name); + log_info(LOGGER_TAG, "Options:"); + log_info(LOGGER_TAG, " -C, --config JSON config file (required)"); + log_info(LOGGER_TAG, " -t, --test-time Receive for N seconds then exit (1..86400)"); + log_info(LOGGER_TAG, " -v, --version Show version"); + log_info(LOGGER_TAG, " --help Show this help"); +} + +/* Same options as dvledtx; everything else comes from the JSON config. */ +static int parse_args(struct rx_app_context* app, int argc, char** argv) { + static struct option long_options[] = { + {"config", required_argument, 0, 'C'}, + {"test-time", required_argument, 0, 't'}, + {"version", no_argument, 0, 'v'}, + {"help", no_argument, 0, '?'}, + {0, 0, 0, 0} + }; + + app->config_file[0] = '\0'; + + int c = 0, option_index = 0; + while ((c = getopt_long(argc, argv, "C:t:v?", long_options, &option_index)) != -1) { + switch (c) { + case 'C': + snprintf(app->config_file, sizeof(app->config_file), "%s", optarg); + break; + case 't': { + char* endptr = NULL; + long val = strtol(optarg, &endptr, 10); + if (endptr == optarg || *endptr != '\0' || val <= 0 || val > 86400) { + log_error(LOGGER_TAG, "Invalid --test-time value '%s' (expected 1..86400)", optarg); + return -1; + } + app->test_time_s = (int)val; + break; + } + case 'v': + printf("dvledrx version %s\n", DVLEDRX_VERSION); + exit(EXIT_SUCCESS); + case '?': + default: + print_help(argv[0]); + return -1; + } + } + + if (app->config_file[0] == '\0') { + print_help(argv[0]); + return -1; + } + return 0; +} + +/* Main application */ +int main(int argc, char** argv) { + struct rx_app_context app; + session_manager_t session_manager; + int ret = 0; + + memset(&app, 0, sizeof(app)); + /* Must precede every other Xlib call: display, RX and main threads share Display* handles. */ + XInitThreads(); + logger_init_from_env(); + + /* Parse arguments */ + if (parse_args(&app, argc, argv) < 0) { + return -1; + } + + if (load_and_validate_rx_config(app.config_file, &app.json_config) != 0) { + return -1; + } + const struct rx_config* cfg = &app.json_config; + + /* Video parameters */ + app.width = (uint32_t)cfg->video.width; + app.height = (uint32_t)cfg->video.height; + app.fps = int_fps_to_st_fps(cfg->video.fps); + app.fmt = cfg->video.fmt[0] != '\0' ? str_fmt_to_st_fmt(cfg->video.fmt) + : ST_FRAME_FMT_YUV422PLANAR10LE; + app.transport_fmt = st_fmt_to_transport_fmt(app.fmt); + + /* 1 session -> single window; N sessions -> composite, or per_output windows. */ + app.st20p_sessions = cfg->num_rx_sessions; + + log_info(LOGGER_TAG, "JSON config: %d rx_session(s) -> %s display mode", + cfg->num_rx_sessions, + strcmp(cfg->display_mode, "per_output") == 0 ? "per_output" : + cfg->num_rx_sessions > 1 ? "composite" : "single"); + + if (cfg->log_level[0] != '\0') { + enum log_level level; + if (logger_parse_level(cfg->log_level, &level)) { + logger_set_level(level); + log_info(LOGGER_TAG, "Log level set to: %s", cfg->log_level); + } else { + log_warn(LOGGER_TAG, "Invalid log_level '%s' in config (use debug|info|warn|error)", cfg->log_level); + } + } + + /* Redirect logs to file when configured (empty = console) */ + if (cfg->log_file[0] != '\0') { + logger_set_file(cfg->log_file, LOGGER_MAX_BYTES); + log_info(LOGGER_TAG, "Logging to file: %s (rotates at %u MB, keeps %d .gz archives)", + cfg->log_file, LOGGER_MAX_BYTES / (1024u * 1024u), LOGGER_MAX_ARCHIVES); + } + + /* Install signal handler */ + signal(SIGINT, rx_app_sig_handler); + signal(SIGTERM, rx_app_sig_handler); + signal(SIGQUIT, rx_app_sig_handler); /* Ctrl+\\ */ + + log_info(LOGGER_TAG, "Signal handlers installed. Press Ctrl+C to stop."); + + /* Initialize session manager */ + if (session_manager_init(&session_manager, &app) < 0) { + log_error(LOGGER_TAG, "Failed to initialize session manager"); + return -1; + } + + /* Start all sessions */ + if (session_manager_start(&session_manager, &app) < 0) { + log_error(LOGGER_TAG, "Failed to start sessions"); + app.exit = true; /* stop the sessions that did start */ + ret = -1; + goto cleanup; + } + + if (app.test_time_s > 0) { + log_info(LOGGER_TAG, "Running for %d seconds...", app.test_time_s); + } else { + log_info(LOGGER_TAG, "Running indefinitely (press Ctrl+C to stop)..."); + } + + /* Main loop: pump X11 events until Ctrl+C, a window close, or --test-time. */ + time_t start_time = time(NULL); + while (!app.exit) { + if (session_manager.composite) { + process_composite_x11_events(session_manager.composite); + } else { + for (int i = 0; i < session_manager.st20p_count; i++) { + struct st20p_rx_ctx* ctx = &session_manager.st20p_sessions[i]; + if (ctx->display.initialized) process_x11_events(&ctx->display); + for (int m = 0; m < ctx->num_mirror_displays; m++) + process_x11_events(&ctx->mirror_display[m]); + } + } + + if (x11_close_requested()) app.exit = true; + if (g_stop_signal) { + log_info(LOGGER_TAG, "Signal received: %d (%s)", (int)g_stop_signal, signal_name(g_stop_signal)); + app.exit = true; + } + if (app.test_time_s > 0 && time(NULL) - start_time >= app.test_time_s) { + log_info(LOGGER_TAG, "Test duration completed"); + app.exit = true; + } + + usleep(10000); /* 10ms: responsive to signals and window events */ + } + + log_info(LOGGER_TAG, "Stopping application..."); + +cleanup: + /* Cleanup session manager */ + session_manager_cleanup(&session_manager); + + log_info(LOGGER_TAG, "Application stopped"); + logger_close(); + return ret; +} diff --git a/dvledrx/src/session_manager.c b/dvledrx/src/session_manager.c new file mode 100644 index 0000000..4d874e4 --- /dev/null +++ b/dvledrx/src/session_manager.c @@ -0,0 +1,949 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include "session_manager.h" +#include "x11_handler.h" +#include "frame_converter.h" +#include "config_reader.h" +#include "logger.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOGGER_TAG "session" + +static bool is_valid_pci_bdf(const char* pci_bdf); + +static bool is_pci_virtual_function(const char* pci_bdf) { + char path[256]; + + if (!is_valid_pci_bdf(pci_bdf)) { + return false; + } + + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/physfn", pci_bdf); + return (access(path, F_OK) == 0); +} + +static bool is_pci_bound_to_vfio(const char* pci_bdf) { + char path[256]; + char link_target[256]; + ssize_t len; + + if (!is_valid_pci_bdf(pci_bdf)) { + return false; + } + + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/driver", pci_bdf); + len = readlink(path, link_target, sizeof(link_target) - 1); + if (len <= 0) { + return false; + } + + link_target[len] = '\0'; + return strstr(link_target, "vfio-pci") != NULL; +} + +/* Validate a PCI BDF string (e.g. "0000:03:10.3" or "03:10.3") so it is safe + * to embed in sysfs paths and shell commands. Only the canonical forms + * "dddd:bb:dd.f" (4-digit domain) and "bb:dd.f" (domain omitted) are + * accepted, with function restricted to 0-7. Reformatting the parsed fields + * and comparing back to the input rejects anything with the wrong digit + * width (e.g. ".." or truncated/extended fields), which would otherwise + * allow directory traversal in sysfs paths. */ +static bool is_valid_pci_bdf(const char* pci_bdf) { + unsigned domain, bus, dev, func; + int consumed; + char canonical[32]; + + if (!pci_bdf || !pci_bdf[0]) { + return false; + } + + consumed = -1; + if (sscanf(pci_bdf, "%4x:%2x:%2x.%1x%n", &domain, &bus, &dev, &func, &consumed) == 4 && + consumed > 0 && pci_bdf[consumed] == '\0' && func <= 7) { + snprintf(canonical, sizeof(canonical), "%04x:%02x:%02x.%01x", domain, bus, dev, func); + return strcasecmp(canonical, pci_bdf) == 0; + } + + consumed = -1; + if (sscanf(pci_bdf, "%2x:%2x.%1x%n", &bus, &dev, &func, &consumed) == 3 && + consumed > 0 && pci_bdf[consumed] == '\0' && func <= 7) { + snprintf(canonical, sizeof(canonical), "%02x:%02x.%01x", bus, dev, func); + return strcasecmp(canonical, pci_bdf) == 0; + } + + return false; +} + +/* + * Prepare the parent PF of an SR-IOV VF for multicast RX. + * + * ixgbe (and similar) NICs reject a VF's DPDK promiscuous request unless the + * parent PF is already in promiscuous mode (kernel: "Enabling VF promisc + * requires PF in promisc"). MTL requests promiscuous once at init and never + * retries, so we must configure the PF *before* mtl_init(): + * - PF: promiscuous on, link up + * - VF: trust on, spoof checking off + * + * The VF itself is bound to vfio-pci (no kernel netdev), but the PF still has a + * kernel netdev we can drive via `ip link`. Requires CAP_NET_ADMIN (run as root). + * Best-effort: logs a warning and returns on any failure. + */ +static void prepare_vf_parent_pf(const char* vf_bdf) { + char path[512]; + char link_target[512]; + ssize_t len; + char pf_bdf[64]; + char pf_iface[64] = {0}; + int vf_index = -1; + DIR* dir; + struct dirent* ent; + char cmd[512]; + + if (!is_valid_pci_bdf(vf_bdf)) { + log_warn(LOGGER_TAG, "VF setup: invalid PCI BDF '%s', skipping PF promisc setup", vf_bdf); + return; + } + + /* Resolve parent PF BDF via /sys/bus/pci/devices//physfn */ + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/physfn", vf_bdf); + len = readlink(path, link_target, sizeof(link_target) - 1); + if (len <= 0) { + log_warn(LOGGER_TAG, "VF setup: cannot resolve parent PF for %s", vf_bdf); + return; + } + link_target[len] = '\0'; + { + const char* base = strrchr(link_target, '/'); + snprintf(pf_bdf, sizeof(pf_bdf), "%s", base ? base + 1 : link_target); + } + + /* Find the PF kernel netdev name via /sys/bus/pci/devices//net/ */ + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/net", pf_bdf); + dir = opendir(path); + if (dir) { + while ((ent = readdir(dir)) != NULL) { + if (ent->d_name[0] == '.') continue; + snprintf(pf_iface, sizeof(pf_iface), "%s", ent->d_name); + break; + } + closedir(dir); + } + if (!pf_iface[0]) { + log_warn(LOGGER_TAG, "VF setup: cannot find PF netdev for %s (PF %s)", vf_bdf, pf_bdf); + return; + } + + /* Determine VF index by matching virtfn* symlinks under the PF */ + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s", pf_bdf); + dir = opendir(path); + if (dir) { + while ((ent = readdir(dir)) != NULL) { + if (strncmp(ent->d_name, "virtfn", 6) != 0) continue; + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/%s", pf_bdf, ent->d_name); + len = readlink(path, link_target, sizeof(link_target) - 1); + if (len <= 0) continue; + link_target[len] = '\0'; + const char* base = strrchr(link_target, '/'); + base = base ? base + 1 : link_target; + if (strcmp(base, vf_bdf) == 0) { + vf_index = atoi(ent->d_name + 6); + break; + } + } + closedir(dir); + } + if (vf_index < 0) { + log_warn(LOGGER_TAG, "VF setup: cannot resolve VF index for %s under PF %s", vf_bdf, pf_bdf); + return; + } + + log_info(LOGGER_TAG, + "VF setup: configuring PF %s (%s) promisc + vf %d trust on / spoofchk off", + pf_bdf, pf_iface, vf_index); + + /* PF promiscuous mode (required before VF promisc is honored) */ + for (const char* p = pf_iface; *p; p++) { + if (!isalnum((unsigned char)*p) && *p != '_' && *p != '-' && *p != '.') { + log_warn(LOGGER_TAG, "VF setup: unsafe PF netdev name '%s', skipping ip link setup", pf_iface); + return; + } + } + snprintf(cmd, sizeof(cmd), "ip link set %s promisc on", pf_iface); + if (system(cmd) != 0) { + log_warn(LOGGER_TAG, "VF setup: '%s' failed (need root/CAP_NET_ADMIN?)", cmd); + } + /* VF trust on */ + snprintf(cmd, sizeof(cmd), "ip link set %s vf %d trust on", pf_iface, vf_index); + if (system(cmd) != 0) { + log_warn(LOGGER_TAG, "VF setup: '%s' failed", cmd); + } + /* VF spoof checking off (allows promiscuous/multicast reception) */ + snprintf(cmd, sizeof(cmd), "ip link set %s vf %d spoofchk off", pf_iface, vf_index); + if (system(cmd) != 0) { + log_warn(LOGGER_TAG, "VF setup: '%s' failed", cmd); + } + /* Bring the PF link up; RxTxApp's nicctl.sh does this so the VF admin queue + * works for DPDK init and traffic reaches the VF. */ + snprintf(cmd, sizeof(cmd), "ip link set %s up", pf_iface); + if (system(cmd) != 0) { + log_warn(LOGGER_TAG, "VF setup: '%s' failed", cmd); + } +} + +/* "per_output": one fullscreen window per session, each on its own HDMI/DP output. */ +static bool is_per_output_mode(const struct rx_app_context* app) { + return strcmp(app->json_config.display_mode, "per_output") == 0; +} + +/* + * st20p_display_thread - dedicated rendering thread (single-session mode). + * + * Takes the newest MTL frame handed over by the RX thread, renders it, then + * returns it to MTL. Runs best-effort: if rendering cannot keep up, the RX + * thread returns superseded frames unrendered — the receive pipeline never waits. + */ +static void* st20p_display_thread(void* arg) { + struct st20p_rx_ctx* ctx = (struct st20p_rx_ctx*)arg; + + log_info(LOGGER_TAG, "ST20P display thread %d started", ctx->idx); + + char dip[INET_ADDRSTRLEN] = ""; + inet_ntop(AF_INET, ctx->dip_addr, dip, sizeof(dip)); + char rx_label[96]; + snprintf(rx_label, sizeof(rx_label), "%s %s:%u", ctx->port_name, dip, ctx->session_udp_port); + + /* Display-rate measurement: count frames actually blitted and report the + * achieved render fps once per second. This is the true on-screen rate, + * independent of the MTL receive rate. */ + struct timespec last; + clock_gettime(CLOCK_MONOTONIC, &last); + int rendered = 0; + + while (!ctx->app->exit) { + struct st_frame* frame = NULL; + + pthread_mutex_lock(&ctx->disp_lock); + while (!ctx->disp_pending && !ctx->app->exit) { + /* Timed wait so the window stays responsive and exit is observed + * promptly even when no new frames are arriving. */ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_nsec += 20 * 1000 * 1000; /* 20ms */ + if (ts.tv_nsec >= 1000000000L) { + ts.tv_sec += 1; + ts.tv_nsec -= 1000000000L; + } + pthread_cond_timedwait(&ctx->disp_cond, &ctx->disp_lock, &ts); + } + frame = ctx->disp_pending; + ctx->disp_pending = NULL; + pthread_mutex_unlock(&ctx->disp_lock); + + if (frame) { + display_frame_x11(&ctx->display, frame); + for (int m = 0; m < ctx->num_mirror_displays; m++) + if (!display_copy_x11(&ctx->mirror_display[m], &ctx->display)) + display_frame_x11(&ctx->mirror_display[m], frame); + rendered++; + /* All puts go through disp_lock so RX and display threads never race in MTL. */ + pthread_mutex_lock(&ctx->disp_lock); + st20p_rx_put_frame(ctx->handle, frame); + pthread_mutex_unlock(&ctx->disp_lock); + } + /* X11 events are pumped by the main thread to avoid concurrent event handling. */ + + /* Report achieved display fps once per second. */ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + double elapsed = (now.tv_sec - last.tv_sec) + (now.tv_nsec - last.tv_nsec) / 1e9; + if (elapsed >= 1.0) { + int rx = __atomic_exchange_n(&ctx->frames_rx_window, 0, __ATOMIC_RELAXED); + pthread_mutex_lock(&ctx->disp_lock); + int skipped = ctx->disp_skipped; + ctx->disp_skipped = 0; + pthread_mutex_unlock(&ctx->disp_lock); + + char outputs[160] = ""; + int n = 0; + const struct x11_display_ctx* wins[1 + MAX_MIRRORS] = {&ctx->display}; + for (int m = 0; m < ctx->num_mirror_displays; m++) wins[1 + m] = &ctx->mirror_display[m]; + for (int w = 0; w <= ctx->num_mirror_displays && (size_t)n < sizeof(outputs); w++) { + if (!wins[w]->initialized) continue; + n += snprintf(outputs + n, sizeof(outputs) - (size_t)n, "%s%s", n ? ", " : "", + wins[w]->output_name); + } + + log_info(LOGGER_TAG, "Session %d [%s]: rx %.2f fps, display %.2f fps, skipped %d -> %s", + ctx->idx, rx_label, rx / elapsed, rendered / elapsed, skipped, outputs); + rendered = 0; + last = now; + } + } + + log_info(LOGGER_TAG, "ST20P display thread %d stopped", ctx->idx); + return NULL; +} + +/* + * publish_frame_to_display - hand a received MTL frame to the display thread + * without copying it. The display thread returns it to MTL after rendering; + * if a previous frame is still waiting unrendered, it is returned here. + * Returns false (caller keeps ownership) when the frame has no data. + */ +static bool publish_frame_to_display(struct st20p_rx_ctx* ctx, struct st_frame* frame) { + if (!frame->addr[0] || frame->data_size == 0) { + return false; + } + + pthread_mutex_lock(&ctx->disp_lock); + struct st_frame* stale = ctx->disp_pending; + ctx->disp_pending = frame; + if (stale) { + st20p_rx_put_frame(ctx->handle, stale); + ctx->disp_skipped++; + } + pthread_cond_signal(&ctx->disp_cond); + pthread_mutex_unlock(&ctx->disp_lock); + return true; +} + +/* + * st20p_rx_thread - per-session ST20P (video) receive thread. + * + * Blocks on st20p_rx_get_frame() until MTL delivers a decoded video frame. + * For each frame received it: + * 1. Logs detailed metadata for the first few frames (format/size sanity check). + * 2. Renders the frame to the display: + * - Single-session mode : directly into a dedicated X11 window. + * - Multi-session mode : writes a strip into the shared composite buffer. + * 3. Returns the frame buffer to the MTL library (mandatory — frees the slot). + * + * Exits cleanly once app->exit is set (e.g. by the SIGINT handler). + */ +static void* st20p_rx_thread(void* arg) { + struct st20p_rx_ctx* ctx = (struct st20p_rx_ctx*)arg; + struct st_frame* frame; + + log_info(LOGGER_TAG, "ST20P RX thread %d started - waiting for incoming frames", ctx->idx); + + /* Single-session display runs on a dedicated thread so the heavy YUV->RGB + * conversion and X11 blit never block the receive loop. Composite + * (multi-session) mode keeps its existing in-loop barrier-synchronised path. + * Mirrors count as displays: the primary output may be disconnected. */ + bool has_window = ctx->display.initialized; + for (int m = 0; m < ctx->num_mirror_displays; m++) + has_window |= ctx->mirror_display[m].initialized; + bool use_disp_thread = !ctx->composite && has_window; + if (use_disp_thread) { + pthread_mutex_init(&ctx->disp_lock, NULL); + pthread_cond_init(&ctx->disp_cond, NULL); + ctx->disp_pending = NULL; + if (pthread_create(&ctx->disp_thread, NULL, st20p_display_thread, ctx) == 0) { + ctx->disp_thread_started = true; + } else { + log_error(LOGGER_TAG, "Failed to start display thread %d; rendering inline", ctx->idx); + pthread_mutex_destroy(&ctx->disp_lock); + pthread_cond_destroy(&ctx->disp_cond); + use_disp_thread = false; + } + } + + while (!ctx->app->exit) { + frame = st20p_rx_get_frame(ctx->handle); + if (!frame) { + usleep(1000); /* 1ms — avoid busy-spinning when no frame is ready */ + continue; + } + + ctx->frames_received++; + __atomic_fetch_add(&ctx->frames_rx_window, 1, __ATOMIC_RELAXED); + bool handed_off = false; + + /* --- Diagnostic: log frame metadata for the first few frames ---------- + * Helps verify that sender format/resolution matches the MTL RX output. */ + /* DEBUG: Print detailed frame information for first few frames */ + if (ctx->frames_received <= 5) { + log_debug(LOGGER_TAG, "ST20P RX session %d: Frame %d received", ctx->idx, ctx->frames_received); + log_debug(LOGGER_TAG, " Format: %d, Size: %zu bytes", frame->fmt, frame->data_size); + log_debug(LOGGER_TAG, " Width: %d, Height: %d", frame->width, frame->height); + log_debug(LOGGER_TAG, " Addresses: Y=%p, U=%p, V=%p", frame->addr[0], frame->addr[1], frame->addr[2]); + log_debug(LOGGER_TAG, " Linesizes: Y=%zu, U=%zu, V=%zu", frame->linesize[0], frame->linesize[1], frame->linesize[2]); + } + + /* Render the frame. + * Two render paths — selected by whether a composite context was wired in: + * + * ctx->composite != NULL (multi-session / legacy single-machine mode): + * Copy this session's strip into the shared composite buffer and wait + * at the pthread_barrier so all strips are present before session 0 + * flushes the full frame to the screen. + * + * ctx->composite == NULL (single-session / standard wall deployment): + * Render the complete 640x1080 frame directly into this receiver's own + * X11 window. No synchronisation with other machines is needed — each + * receiver is independent. */ + if (ctx->composite) { + /* Multi-session: write strip/crop and synchronise with peer sessions */ + if (ctx->crop.w > 0 && ctx->crop.h > 0) { + /* Crop-aware composite: place frame at specific coordinates */ + composite_write_crop(ctx->composite, frame, &ctx->crop); + } else { + /* Legacy equal-width strip composite */ + composite_write_strip(ctx->composite, frame, ctx->idx); + } + } else if (has_window) { + /* Single-session: hand the frame to the dedicated display thread, + * which returns it to MTL after rendering, so receive never stalls. */ + if (use_disp_thread) { + handed_off = publish_frame_to_display(ctx, frame); + } else { + /* Fallback: display thread unavailable, render inline. */ + display_frame_x11(&ctx->display, frame); + for (int m = 0; m < ctx->num_mirror_displays; m++) + if (!display_copy_x11(&ctx->mirror_display[m], &ctx->display)) + display_frame_x11(&ctx->mirror_display[m], frame); + process_x11_events(&ctx->display); + for (int m = 0; m < ctx->num_mirror_displays; m++) + process_x11_events(&ctx->mirror_display[m]); + } + } + + /* Return the frame buffer to MTL so it can be reused for the next frame. + * Frames handed to the display thread are returned by it instead. */ + if (!handed_off) st20p_rx_put_frame(ctx->handle, frame); + + /* Periodic progress log — printed every 50 frames to avoid console spam */ + if (ctx->frames_received % 50 == 0) { + log_info(LOGGER_TAG, "ST20P session %d: received %d frames", + ctx->idx, ctx->frames_received); + } + } + + log_info(LOGGER_TAG, "ST20P RX thread %d stopped, received %d frames", ctx->idx, ctx->frames_received); + + /* Stop and clean up the dedicated display thread (single-session mode). */ + if (ctx->disp_thread_started) { + pthread_mutex_lock(&ctx->disp_lock); + pthread_cond_signal(&ctx->disp_cond); /* wake it so it observes app->exit */ + pthread_mutex_unlock(&ctx->disp_lock); + pthread_join(ctx->disp_thread, NULL); + ctx->disp_thread_started = false; + if (ctx->disp_pending) { + st20p_rx_put_frame(ctx->handle, ctx->disp_pending); + ctx->disp_pending = NULL; + } + pthread_mutex_destroy(&ctx->disp_lock); + pthread_cond_destroy(&ctx->disp_cond); + } + + return NULL; +} + +/* + * get_transport_format - map a decoded pixel format to the ST2110-20 + * on-wire transport format. + * + * MTL requires callers to specify the compressed transport packing separately + * from the decoded output format. This helper centralises that mapping so + * it doesn't have to be repeated at each session creation call site. + */ +static enum st20_fmt get_transport_format(enum st_frame_fmt frame_fmt) { + switch (frame_fmt) { + case ST_FRAME_FMT_YUV422PLANAR10LE: + return ST20_FMT_YUV_422_10BIT; + case ST_FRAME_FMT_YUV420PLANAR8: + return ST20_FMT_YUV_420_8BIT; + case ST_FRAME_FMT_YUV420CUSTOM8: + return ST20_FMT_YUV_420_8BIT; + case ST_FRAME_FMT_YUV422PLANAR12LE: + return ST20_FMT_YUV_422_12BIT; + case ST_FRAME_FMT_YUV444PLANAR10LE: + return ST20_FMT_YUV_444_10BIT; + case ST_FRAME_FMT_YUV444PLANAR12LE: + return ST20_FMT_YUV_444_12BIT; + case ST_FRAME_FMT_GBRPLANAR10LE: + return ST20_FMT_RGB_10BIT; + case ST_FRAME_FMT_GBRPLANAR12LE: + return ST20_FMT_RGB_12BIT; + default: + log_warn(LOGGER_TAG, "Unknown frame format %d, using YUV_422_10BIT", frame_fmt); + return ST20_FMT_YUV_422_10BIT; + } +} + +/* Create ST20P session */ +static int create_st20p_session(struct rx_app_context* app, struct st20p_rx_ctx* ctx, int session_idx) { + struct st20p_rx_ops ops; + bool vf_mode = is_pci_virtual_function(ctx->port_name); + + memset(&ops, 0, sizeof(ops)); + + /* Session identification */ + snprintf(ctx->session_name, sizeof(ctx->session_name), "st20p_rx_%d", session_idx); + ops.name = ctx->session_name; + ops.priv = ctx; + + /* Network configuration (from the session's JSON rx_sessions entry). */ + ops.port.num_port = 1; + memcpy(ops.port.ip_addr[MTL_SESSION_PORT_P], ctx->dip_addr, MTL_IP_ADDR_LEN); + snprintf(ops.port.port[MTL_SESSION_PORT_P], MTL_PORT_MAX_LEN, "%s", ctx->port_name); + ops.port.udp_port[MTL_SESSION_PORT_P] = ctx->session_udp_port; + ops.port.payload_type = ctx->session_payload_type; + + /* Video parameters. + * When a crop region is defined, the stream carries crop.w × crop.h. */ + if (ctx->crop.w > 0 && ctx->crop.h > 0) { + ops.width = (uint32_t)ctx->crop.w; + ops.height = (uint32_t)ctx->crop.h; + } else { + ops.width = app->width; + ops.height = app->height; + } + ops.fps = app->fps; + ops.transport_fmt = (app->transport_fmt != 0) + ? (enum st20_fmt)app->transport_fmt + : get_transport_format(app->fmt); + ops.output_fmt = app->fmt; + + /* Device and flags */ + ops.device = ST_PLUGIN_DEVICE_AUTO; + ops.framebuff_cnt = 8; + ops.flags = ST20P_RX_FLAG_BLOCK_GET | ST20P_RX_FLAG_RECEIVE_INCOMPLETE_FRAME; + + /* Create RX handle */ + ctx->handle = st20p_rx_create(app->mtl, &ops); + if (!ctx->handle) { + log_error(LOGGER_TAG, "Failed to create ST20P RX session %d", session_idx); + log_error(LOGGER_TAG, "Possible causes: insufficient RX queues, network configuration, or DPDK setup"); + if (vf_mode) { + log_error(LOGGER_TAG, + "Detected PCI VF on %s. Current MTL build may not support rte_flow for ixgbe VF.", + ctx->port_name); + log_error(LOGGER_TAG, + "Use PF/iavf-capable NIC path, or rebuild/install MTL with explicit net_ixgbe_vf no-flow handling."); + } + log_error(LOGGER_TAG, "Check MTL logs above for detailed error information"); + return -1; + } + + ctx->frame_size = st20p_rx_frame_size(ctx->handle); + log_info(LOGGER_TAG, "ST20P RX session %d created, frame size: %zu bytes", session_idx, ctx->frame_size); + + /* Display initialisation — two mutually exclusive code paths: + * + * Single-session (st20p_sessions == 1): + * Normal deployment on a 3-receiver wall. Each receiver process has + * exactly one ST20P session and opens its own dedicated X11 window + * sized to the panel resolution (e.g. 640x1080). Frames are rendered + * directly via display_frame_x11() in st20p_rx_thread(). + * + * Multi-session (st20p_sessions > 1): + * Legacy single-machine composite mode. A shared composite_x11_ctx is + * allocated in session_manager_start() and wired into every session's + * ctx->composite pointer. Each thread writes its strip via + * composite_write_strip() and a pthread_barrier synchronises the flush. + * The per-session x11_display_ctx is not used in this path. + * + * display_mode "per_output" (any session count): + * Each session gets its own borderless fullscreen window on the XRandR + * output named in rx_sessions[i].output, or the i-th active monitor. + * rx_sessions[i].window switches it to a fixed-size window on that output. */ + bool per_output = is_per_output_mode(app); + if (app->st20p_sessions == 1 || per_output) { + /* For single-session: use crop dimensions if available, else full video */ + uint32_t disp_w = (ctx->crop.w > 0) ? (uint32_t)ctx->crop.w : app->width; + uint32_t disp_h = (ctx->crop.h > 0) ? (uint32_t)ctx->crop.h : app->height; + const char* output_name = NULL; + struct crop_config window_rect; + const struct crop_config* window_ptr = NULL; + if (session_idx < app->json_config.num_rx_sessions) { + const struct rx_session_config* sc = &app->json_config.rx_sessions[session_idx]; + bool has_scale = sc->scale_w > 0 && sc->scale_h > 0; + output_name = sc->output; + if (has_scale) { + disp_w = (uint32_t)sc->scale_w; + disp_h = (uint32_t)sc->scale_h; + } + if (sc->has_window || has_scale) { + /* window gives the position; scale (if set) gives the size. */ + window_rect = sc->has_window ? sc->window : (struct crop_config){0, 0, 0, 0}; + if (has_scale) { + window_rect.w = sc->scale_w; + window_rect.h = sc->scale_h; + } + if (window_rect.w == 0) window_rect.w = (int)disp_w; + if (window_rect.h == 0) window_rect.h = (int)disp_h; + window_ptr = &window_rect; + } + } + if (init_x11_display(&ctx->display, disp_w, disp_h, session_idx, + output_name, per_output, window_ptr) < 0) { + log_warn(LOGGER_TAG, "Failed to initialize X11 display for session %d", session_idx); + /* Continue without display */ + } + + if (per_output && session_idx < app->json_config.num_rx_sessions) { + const struct rx_session_config* sc = &app->json_config.rx_sessions[session_idx]; + for (int m = 0; m < sc->num_mirrors; m++) { + const struct display_placement* pl = &sc->mirrors[m]; + struct crop_config rect = pl->window; + if (pl->scale_w > 0 && pl->scale_h > 0) { + rect.w = pl->scale_w; + rect.h = pl->scale_h; + } + if (rect.w == 0) rect.w = (ctx->crop.w > 0) ? ctx->crop.w : (int)app->width; + if (rect.h == 0) rect.h = (ctx->crop.h > 0) ? ctx->crop.h : (int)app->height; + struct x11_display_ctx* md = &ctx->mirror_display[ctx->num_mirror_displays]; + if (init_x11_display(md, (uint32_t)rect.w, (uint32_t)rect.h, session_idx, + pl->output, true, &rect) == 0) { + ctx->num_mirror_displays++; + } else { + log_warn(LOGGER_TAG, "Failed to mirror session %d on %s", session_idx, pl->output); + } + } + } + } + + return 0; +} + +/* + * session_manager_init - initialise the MTL library and prepare the session + * manager structure. + * + * Binds MTL to every NIC in the JSON "interfaces" array, each with its own + * source IP, and provisions RX queues per NIC for the sessions on it plus a + * small reserve for internal MTL use. + * + * Must be called before session_manager_start(). + * Returns 0 on success, -1 on failure. + */ +int session_manager_init(session_manager_t* manager, struct rx_app_context* app) { + struct mtl_init_params mtl_params; + bool any_vf = false; + int num_ports; + uint16_t sessions_on_port[MTL_PORT_MAX] = {0}; + + if (!manager || !app) { + return -1; + } + + memset(manager, 0, sizeof(*manager)); + + /* One MTL port per JSON interface (MTL port index == position in the array). */ + const struct rx_config* cfg = &app->json_config; + num_ports = cfg->num_interfaces; + + for (int i = 0; i < app->st20p_sessions; i++) { + int p = rx_config_port_for_nic(cfg, cfg->rx_sessions[i].nic_index); + if (p >= 0 && p < num_ports) sessions_on_port[p]++; + } + + memset(&mtl_params, 0, sizeof(mtl_params)); + mtl_params.num_ports = (uint8_t)num_ports; + mtl_params.log_level = MTL_LOG_LEVEL_INFO; + mtl_params.flags |= MTL_FLAG_DEV_AUTO_START_STOP; + mtl_params.flags |= MTL_FLAG_RX_VIDEO_MIGRATE; + if (cfg->tasklet_sleep) { + /* Scheduler sleeps when idle; RX sessions cap it at ~128 packet times. */ + mtl_params.flags |= MTL_FLAG_TASKLET_SLEEP; + log_info(LOGGER_TAG, "MTL tasklet sleep enabled"); + } + mtl_params.nb_rx_desc = 4096; + + for (int p = 0; p < num_ports; p++) { + const char* name = cfg->interfaces[p].name; + uint8_t sip[MTL_IP_ADDR_LEN]; + inet_pton(AF_INET, cfg->interfaces[p].sip, sip); /* validated at load */ + + snprintf(mtl_params.port[p], MTL_PORT_MAX_LEN, "%s", name); + memcpy(mtl_params.sip_addr[p], sip, MTL_IP_ADDR_LEN); + mtl_params.pmd[p] = mtl_pmd_by_port_name(name); + + bool vf = is_pci_virtual_function(name); + if (vf) { + any_vf = true; + log_warn(LOGGER_TAG, "VF NIC detected on %s%s", name, + is_pci_bound_to_vfio(name) ? " (vfio-pci)" : ""); + /* ixgbe rejects the VF's promiscuous request unless the parent PF is + * already promiscuous, so prepare it before mtl_init(). */ + prepare_vf_parent_pf(name); + } + + /* One RX queue per session + 2 for MTL housekeeping (ARP, PTP, ...). + * VF/CNI mode uses only the system queue; extra queues would let RSS + * scatter packets away from it. */ + uint16_t rx_queues = vf ? 0 : (uint16_t)(sessions_on_port[p] + 2); + mtl_para_rx_queues_cnt_set(&mtl_params, (enum mtl_port)p, rx_queues); + + log_info(LOGGER_TAG, "MTL port %d: %s sip %s, %u session(s), %u RX queues%s", + p, name, cfg->interfaces[p].sip, + sessions_on_port[p], rx_queues, vf ? " (VF)" : ""); + } + + if (any_vf) { + /* Mirror RxTxApp VF-friendly path: force CNI RX instead of per-session rte_flow. */ + mtl_params.flags |= MTL_FLAG_RX_USE_CNI; + mtl_params.flags |= MTL_FLAG_RX_UDP_PORT_ONLY; + mtl_params.flags |= MTL_FLAG_NIC_RX_PROMISCUOUS; + log_warn(LOGGER_TAG, "VF NIC(s) present: enabling rx_use_cni + rx_udp_port_only + promiscuous"); + } + + manager->mtl = mtl_init(&mtl_params); + if (!manager->mtl) { + log_error(LOGGER_TAG, "MTL initialization failed"); + return -1; + } + + app->mtl = manager->mtl; /* Back-fill app context so session creators can reach the handle */ + + log_info(LOGGER_TAG, "MTL initialized successfully with %d port(s)", num_ports); + log_info(LOGGER_TAG, "Video format: %dx%d, FPS: %d", app->width, app->height, + (app->fps == ST_FPS_P25) ? 25 : (app->fps == ST_FPS_P30) ? 30 : + (app->fps == ST_FPS_P50) ? 50 : 60); + log_info(LOGGER_TAG, "ST20P sessions: %d", app->st20p_sessions); + + manager->st20p_count = app->st20p_sessions; + + return 0; +} + +static const struct x11_display_ctx* session_window(const struct st20p_rx_ctx* s, int w) { + return w == 0 ? &s->display : &s->mirror_display[w - 1]; +} + +/* One block per display output listing every session window placed on it. */ +static void log_display_layout(const session_manager_t* manager) { + const char* seen[MAX_RX_SESSIONS * (MAX_MIRRORS + 1)]; + int nseen = 0; + + log_info(LOGGER_TAG, "Display layout:"); + for (int i = 0; i < manager->st20p_count; i++) { + const struct st20p_rx_ctx* s = &manager->st20p_sessions[i]; + for (int w = 0; w <= s->num_mirror_displays; w++) { + const struct x11_display_ctx* d = session_window(s, w); + if (!d->initialized) continue; + bool dup = false; + for (int k = 0; k < nseen && !dup; k++) dup = strcmp(seen[k], d->output_name) == 0; + if (dup || nseen >= (int)(sizeof(seen) / sizeof(seen[0]))) continue; + seen[nseen++] = d->output_name; + + log_info(LOGGER_TAG, " Output %s:", d->output_name); + for (int j = 0; j < manager->st20p_count; j++) { + const struct st20p_rx_ctx* t = &manager->st20p_sessions[j]; + for (int v = 0; v <= t->num_mirror_displays; v++) { + const struct x11_display_ctx* e = session_window(t, v); + if (!e->initialized || strcmp(e->output_name, d->output_name) != 0) continue; + log_info(LOGGER_TAG, " session %d: window %ux%u at (%d,%d) <- stream %dx%d (crop %d,%d)%s", + j, e->width, e->height, e->out_x, e->out_y, t->crop.w, t->crop.h, + t->crop.x, t->crop.y, v ? " [mirror]" : ""); + } + } + } + } + for (int i = 0; i < manager->st20p_count; i++) { + if (!manager->st20p_sessions[i].display.initialized) + log_warn(LOGGER_TAG, " session %d: no window (display init failed)", i); + } +} + +/* + * session_manager_start - allocate and launch all ST20P RX sessions. + * + * For ST20P sessions: + * - In single-session mode (st20p_count == 1, normal wall deployment): + * Creates one session, opens a dedicated per-receiver X11 window. + * - In multi-session mode (st20p_count > 1, legacy composite mode): + * Creates N sessions and a shared composite X11 window. + * + * Each session gets its own receive thread that blocks on st20p_rx_get_frame(). + * Returns 0 on success, -1 on any failure. + */ +int session_manager_start(session_manager_t* manager, struct rx_app_context* app) { + if (!manager || !app) { + return -1; + } + + /* --- ST20P (video) sessions ------------------------------------------- */ + if (manager->st20p_count > 0) { + manager->st20p_sessions = calloc(manager->st20p_count, sizeof(struct st20p_rx_ctx)); + if (!manager->st20p_sessions) { + log_error(LOGGER_TAG, "Failed to allocate ST20P contexts"); + return -1; + } + /* Set early so session_manager_stop() also joins sessions started before a failure. */ + manager->running = true; + + /* Display setup — chosen based on session count: + * + * st20p_count == 1 (single display mode): + * composite stays NULL. Dedicated X11 window initialised inside + * create_st20p_session(). + * + * st20p_count > 1 (multi-display / crop mode from JSON config): + * Compute the bounding box of all crop regions and create a composite + * window of exactly that size. Each session thread writes its crop + * region, waits at a barrier, then one thread flushes the display. */ + if (manager->st20p_count > 1 && !is_per_output_mode(app)) { + manager->composite = calloc(1, sizeof(struct composite_x11_ctx)); + if (!manager->composite) { + log_error(LOGGER_TAG, "Failed to allocate composite display context"); + return -1; + } + + /* Compute bounding box from all crop regions */ + uint32_t comp_width = app->width; + uint32_t comp_height = app->height; + + if (app->json_config.num_rx_sessions > 0) { + uint32_t max_x = 0, max_y = 0; + for (int i = 0; i < app->json_config.num_rx_sessions; i++) { + const struct crop_config* c = &app->json_config.rx_sessions[i].crop; + uint32_t right = (uint32_t)(c->x + c->w); + uint32_t bottom = (uint32_t)(c->y + c->h); + if (right > max_x) max_x = right; + if (bottom > max_y) max_y = bottom; + } + if (max_x > 0) comp_width = max_x; + if (max_y > 0) comp_height = max_y; + log_info(LOGGER_TAG, "Composite bounding box: %ux%u (from %d crop regions)", + comp_width, comp_height, app->json_config.num_rx_sessions); + } + + if (init_composite_x11_display(manager->composite, + comp_width, comp_height, + manager->st20p_count) < 0) { + log_warn(LOGGER_TAG, "Failed to initialise composite X11 display"); + free(manager->composite); + manager->composite = NULL; + } + } + + for (int i = 0; i < manager->st20p_count; i++) { + manager->st20p_sessions[i].app = app; + manager->st20p_sessions[i].idx = i; + /* Wire composite pointer (NULL for single-session) */ + manager->st20p_sessions[i].composite = manager->composite; + + /* NIC, multicast group, port and crop from the JSON rx_sessions entry. */ + const struct rx_session_config* ts = &app->json_config.rx_sessions[i]; + int p = rx_config_port_for_nic(&app->json_config, ts->nic_index); + const struct interface_config* ifc = &app->json_config.interfaces[p]; /* validated */ + snprintf(manager->st20p_sessions[i].port_name, MTL_PORT_MAX_LEN, "%s", ifc->name); + inet_pton(AF_INET, ifc->dip, manager->st20p_sessions[i].dip_addr); + manager->st20p_sessions[i].session_udp_port = (uint16_t)ts->udp_port; + manager->st20p_sessions[i].session_payload_type = (uint8_t)ts->payload_type; + manager->st20p_sessions[i].crop = ts->crop; + log_info(LOGGER_TAG, "Session %d: nic %d (%s) %s:%d, payload %d, crop (%d,%d %dx%d)", + i, ts->nic_index, ifc->name, ifc->dip, ts->udp_port, ts->payload_type, + ts->crop.x, ts->crop.y, ts->crop.w, ts->crop.h); + + if (create_st20p_session(app, &manager->st20p_sessions[i], i) < 0) { + return -1; + } + + if (pthread_create(&manager->st20p_sessions[i].thread, NULL, st20p_rx_thread, &manager->st20p_sessions[i]) != 0) { + log_error(LOGGER_TAG, "Failed to create ST20P thread %d", i); + return -1; + } + } + } + + manager->running = true; + if (!manager->composite) log_display_layout(manager); + log_info(LOGGER_TAG, "All RX sessions started successfully"); + + return 0; +} + +/* + * session_manager_stop - gracefully stop all running sessions. + * + * Wakes any threads blocked in st20p_rx_get_frame(), waits for each + * thread to exit, then frees the MTL session handles and display contexts. + * Safe to call more than once (guarded by manager->running). + */ +void session_manager_stop(session_manager_t* manager) { + if (!manager || !manager->running) { + return; + } + + manager->running = false; + + /* A session may wait at the composite barrier for a peer that stopped or never started. */ + composite_abort(manager->composite); + + /* --- Stop ST20P (video) sessions -------------------------------------- */ + if (manager->st20p_sessions) { + for (int i = 0; i < manager->st20p_count; i++) { + if (manager->st20p_sessions[i].handle) { + /* Unblock st20p_rx_get_frame() so the thread observes app->exit */ + st20p_rx_wake_block(manager->st20p_sessions[i].handle); + + if (manager->st20p_sessions[i].thread) { + pthread_join(manager->st20p_sessions[i].thread, NULL); + } + + st20p_rx_free(manager->st20p_sessions[i].handle); + + /* Per-session display (single-session mode only) */ + cleanup_x11_display(&manager->st20p_sessions[i].display); + for (int m = 0; m < manager->st20p_sessions[i].num_mirror_displays; m++) + cleanup_x11_display(&manager->st20p_sessions[i].mirror_display[m]); + } + } + + /* Composite display (multi-session mode) */ + if (manager->composite) { + cleanup_composite_x11_display(manager->composite); + free(manager->composite); + manager->composite = NULL; + } + } +} + +/* + * session_manager_cleanup - full teardown of the session manager. + * + * Calls session_manager_stop() to join threads and free session handles, + * then releases the session context arrays and shuts down the MTL library + * (which releases DPDK resources and hugepage mappings). + */ +void session_manager_cleanup(session_manager_t* manager) { + if (!manager) { + return; + } + + /* Stop and join all session threads; free handles and display contexts */ + session_manager_stop(manager); + + /* Release the context arrays that were allocated in session_manager_start() */ + if (manager->st20p_sessions) { + free(manager->st20p_sessions); + manager->st20p_sessions = NULL; + } + + /* Shut down MTL — releases DPDK port, hugepages, and kernel NIC binding */ + if (manager->mtl) { + mtl_uninit(manager->mtl); + manager->mtl = NULL; + } + + manager->st20p_count = 0; + manager->running = false; +} + +/* Check if sessions are running */ +bool session_manager_is_running(const session_manager_t* manager) { + return manager && manager->running; +} \ No newline at end of file diff --git a/dvledrx/src/x11_handler.c b/dvledrx/src/x11_handler.c new file mode 100644 index 0000000..6d60fa6 --- /dev/null +++ b/dvledrx/src/x11_handler.c @@ -0,0 +1,1455 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include "x11_handler.h" +#include "config_reader.h" +#include "frame_converter.h" +#include "logger.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Include MTL headers for st_frame definition +#include "st_pipeline_api.h" + +#define LOGGER_TAG "x11" + +/* X11 Error handler */ +static int x11_error_handler(Display *display, XErrorEvent *error) { + char error_text[256]; + XGetErrorText(display, error->error_code, error_text, sizeof(error_text)); + + log_error(LOGGER_TAG, "X11 Error: %s", error_text); + log_error(LOGGER_TAG, " Request: %d (0x%02x)", error->request_code, error->request_code); + log_error(LOGGER_TAG, " Minor code: %d", error->minor_code); + log_error(LOGGER_TAG, " Resource ID: 0x%lx", error->resourceid); + log_error(LOGGER_TAG, " Serial: %lu", error->serial); + + // Don't exit on error, just log it + return 0; +} + +/* X11 IO Error handler */ +static int x11_io_error_handler(Display *display) { + log_error(LOGGER_TAG, "X11 IO Error: Connection lost to X server"); + exit(1); +} + +/* ------------------------------------------------------------------ + * MIT-SHM image buffers + * ------------------------------------------------------------------ */ +static int shm_major_opcode = -1; +static volatile int shm_attach_failed; + +static int shm_attach_error_handler(Display* dpy, XErrorEvent* error) { + if (error->request_code == shm_major_opcode) { + shm_attach_failed = 1; + return 0; + } + return x11_error_handler(dpy, error); +} + +/* Try to create a w x h XImage backed by a SysV shared memory segment that + * the X server attaches to. Only called from the main thread. */ +static XImage* create_shm_image(Display* dpy, Visual* vis, int depth, + uint32_t w, uint32_t h, XShmSegmentInfo** out_shm) { + int ev_base, err_base; + if (!XShmQueryExtension(dpy) || + !XQueryExtension(dpy, "MIT-SHM", &shm_major_opcode, &ev_base, &err_base)) { + return NULL; + } + + XShmSegmentInfo* shm = calloc(1, sizeof(*shm)); + if (!shm) return NULL; + + XImage* img = XShmCreateImage(dpy, vis, (unsigned)depth, ZPixmap, NULL, shm, w, h); + if (!img) { + free(shm); + return NULL; + } + /* Converter and scaler assume a tightly packed 32-bit BGRA row. */ + if (img->bits_per_pixel != 32 || img->bytes_per_line != (int)(w * 4)) { + XDestroyImage(img); + free(shm); + return NULL; + } + + shm->shmid = shmget(IPC_PRIVATE, (size_t)img->bytes_per_line * img->height, IPC_CREAT | 0600); + if (shm->shmid < 0) { + XDestroyImage(img); + free(shm); + return NULL; + } + /* Rootless Xorg runs as the desktop user; under sudo give it the segment. */ + const char* sudo_uid = getenv("SUDO_UID"); + if (geteuid() == 0 && sudo_uid) { + char* end = NULL; + unsigned long uid = strtoul(sudo_uid, &end, 10); + struct shmid_ds ds; + if (end && *end == '\0' && shmctl(shm->shmid, IPC_STAT, &ds) == 0) { + ds.shm_perm.uid = (uid_t)uid; + shmctl(shm->shmid, IPC_SET, &ds); + } + } + shm->shmaddr = img->data = shmat(shm->shmid, NULL, 0); + if (shm->shmaddr == (char*)-1) { + shmctl(shm->shmid, IPC_RMID, NULL); + img->data = NULL; + XDestroyImage(img); + free(shm); + return NULL; + } + shm->readOnly = False; + + XSync(dpy, False); + shm_attach_failed = 0; + int (*prev_handler)(Display*, XErrorEvent*) = XSetErrorHandler(shm_attach_error_handler); + Status ok = XShmAttach(dpy, shm); + XSync(dpy, False); + XSetErrorHandler(prev_handler); + /* Mark for removal now so the segment is freed even if we crash. */ + shmctl(shm->shmid, IPC_RMID, NULL); + + if (!ok || shm_attach_failed) { + shmdt(shm->shmaddr); + img->data = NULL; + XDestroyImage(img); + free(shm); + return NULL; + } + + *out_shm = shm; + return img; +} + +static void destroy_image_buffer(Display* dpy, XImage* img, char* data, XShmSegmentInfo* shm) { + if (shm) { + XShmDetach(dpy, shm); + XSync(dpy, False); /* server must detach before we unmap */ + if (img) { + img->data = NULL; + XDestroyImage(img); + } + shmdt(shm->shmaddr); + free(shm); + } else { + if (img) { + img->data = NULL; /* we own data */ + XDestroyImage(img); + } + free(data); + } +} + +/* Allocate display->ximage/image_data of w x h, preferring MIT-SHM. */ +static int create_image_buffer(struct x11_display_ctx* display, uint32_t w, uint32_t h) { + Display* dpy = (Display*)display->display; + Visual* vis = (Visual*)display->visual; + XShmSegmentInfo* shm = NULL; + XImage* img = NULL; + + if (display->use_shm) { + img = create_shm_image(dpy, vis, display->depth, w, h, &shm); + if (!img) { + log_warn(LOGGER_TAG, "MIT-SHM unavailable (remote display?), falling back to XPutImage"); + display->use_shm = false; + } + } + + if (!img) { + char* data = malloc((size_t)w * h * 4); + if (!data) { + log_error(LOGGER_TAG, "Cannot allocate image buffer (%ux%u)", w, h); + return -1; + } + img = XCreateImage(dpy, vis, (unsigned)display->depth, ZPixmap, 0, data, w, h, 32, (int)(w * 4)); + if (!img) { + log_error(LOGGER_TAG, "Cannot create XImage (depth=%d, size=%ux%u)", display->depth, w, h); + free(data); + return -1; + } + } + + memset(img->data, 0, (size_t)w * h * 4); + if (display->depth == 32) { /* ARGB window: start opaque black, not transparent */ + uint32_t* p = (uint32_t*)img->data; + for (size_t i = 0; i < (size_t)w * h; i++) p[i] = 0xFF000000u; + } + display->ximage = img; + display->image_data = img->data; + display->shminfo = shm; + display->width = w; + display->height = h; + return 0; +} + +/* Blit the whole image to the window. Caller must hold display->xlock. */ +static void put_image_locked(struct x11_display_ctx* display) { + Display* dpy = (Display*)display->display; + Window win = (Window)(uintptr_t)display->window; + GC gc = (GC)display->gc; + XImage* img = (XImage*)display->ximage; + + if (!img) return; + if (display->shminfo) { + XShmPutImage(dpy, win, gc, img, 0, 0, 0, 0, display->width, display->height, False); + /* Server reads the segment asynchronously; wait so the next frame cannot + * overwrite it mid-read. Runs on the display thread, not the RX thread. */ + XSync(dpy, False); + } else { + XPutImage(dpy, win, gc, img, 0, 0, 0, 0, display->width, display->height); + XFlush(dpy); + } +} + +/* ------------------------------------------------------------------ + * XRandR output lookup / fullscreen placement + * ------------------------------------------------------------------ */ + +/* Find an active monitor by XRandR output name, or by index when name is + * empty. mon_idx receives the monitor's Xinerama-order index. */ +static int find_output_geometry(Display* dpy, Window root, const char* name, int index, + int* x, int* y, int* w, int* h, int* mon_idx, + char* matched, size_t matched_len) { + int ev_base, err_base; + if (!XRRQueryExtension(dpy, &ev_base, &err_base)) { + log_error(LOGGER_TAG, "XRandR extension not available on this X server"); + return -1; + } + + int nmon = 0; + XRRMonitorInfo* mons = XRRGetMonitors(dpy, root, True, &nmon); + XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, root); + bool by_name = name && name[0]; + int found = -1; + + for (int m = 0; m < nmon; m++) { + for (int o = 0; o < mons[m].noutput; o++) { + XRROutputInfo* info = res ? XRRGetOutputInfo(dpy, res, mons[m].outputs[o]) : NULL; + const char* oname = info ? info->name : "?"; + log_debug(LOGGER_TAG, "Monitor %d: output %s %dx%d+%d+%d", m, oname, + mons[m].width, mons[m].height, mons[m].x, mons[m].y); + if (found < 0 && ((by_name && strcmp(oname, name) == 0) || (!by_name && m == index))) { + found = m; + snprintf(matched, matched_len, "%s", oname); + } + if (info) XRRFreeOutputInfo(info); + } + } + + if (found >= 0) { + *x = mons[found].x; + *y = mons[found].y; + *w = mons[found].width; + *h = mons[found].height; + *mon_idx = found; + } else { + log_warn(LOGGER_TAG, "No active monitor matches %s%s%s (index %d); %d active monitor(s)", + by_name ? "output '" : "", by_name ? name : "", by_name ? "'" : "", index, nmon); + } + + if (res) XRRFreeScreenResources(res); + if (mons) XRRFreeMonitors(mons); + return found >= 0 ? 0 : -1; +} + +/* Pre-map hints: fixed position/size, no decorations, EWMH fullscreen. */ +static void set_position_hints(Display* dpy, Window win, int x, int y, uint32_t w, uint32_t h) { + XSizeHints* hints = XAllocSizeHints(); + if (hints) { + hints->flags = USPosition | USSize; + hints->x = x; + hints->y = y; + hints->width = (int)w; + hints->height = (int)h; + XSetWMNormalHints(dpy, win, hints); + XFree(hints); + } +} + +static void set_decorations(Display* dpy, Window win, bool on) { + long motif[5] = {2 /* MWM_HINTS_DECORATIONS */, 0, on ? 1 /* MWM_DECOR_ALL */ : 0, 0, 0}; + Atom motif_atom = XInternAtom(dpy, "_MOTIF_WM_HINTS", False); + XChangeProperty(dpy, win, motif_atom, motif_atom, 32, PropModeReplace, + (unsigned char*)motif, 5); +} + +static void set_fullscreen_hints(Display* dpy, Window win, int x, int y, uint32_t w, uint32_t h) { + set_position_hints(dpy, win, x, y, w, h); + set_decorations(dpy, win, false); + Atom fs = XInternAtom(dpy, "_NET_WM_STATE_FULLSCREEN", False); + XChangeProperty(dpy, win, XInternAtom(dpy, "_NET_WM_STATE", False), XA_ATOM, 32, + PropModeReplace, (unsigned char*)&fs, 1); +} + +/* Ask the WM to keep the fullscreen window on exactly one monitor. */ +static void pin_fullscreen_to_monitor(Display* dpy, int screen, Window win, int mon_idx) { + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xclient.type = ClientMessage; + ev.xclient.window = win; + ev.xclient.message_type = XInternAtom(dpy, "_NET_WM_FULLSCREEN_MONITORS", False); + ev.xclient.format = 32; + ev.xclient.data.l[0] = mon_idx; /* top */ + ev.xclient.data.l[1] = mon_idx; /* bottom */ + ev.xclient.data.l[2] = mon_idx; /* left */ + ev.xclient.data.l[3] = mon_idx; /* right */ + ev.xclient.data.l[4] = 1; /* source: normal application */ + XSendEvent(dpy, RootWindow(dpy, screen), False, + SubstructureRedirectMask | SubstructureNotifyMask, &ev); +} + +/* ------------------------------------------------------------------ + * Wall mode <-> normal window (Escape) + * ------------------------------------------------------------------ */ +#define MAX_WALL_WINDOWS (MAX_RX_SESSIONS * (1 + MAX_MIRRORS)) /* primary + mirrors */ + +static pthread_mutex_t g_wall_lock = PTHREAD_MUTEX_INITIALIZER; +static struct x11_display_ctx* g_wall[MAX_WALL_WINDOWS]; +static int g_nwall; +static bool g_all_windowed; +static bool g_esc_grabbed; +static bool g_close_requested; +static bool g_esc_down; +static long long g_esc_last_ms; + +static long long now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +bool x11_close_requested(void) { + return __atomic_load_n(&g_close_requested, __ATOMIC_RELAXED); +} + +static void handle_close_message(Display* dpy, const XEvent* ev) { + if (ev->xclient.message_type == XInternAtom(dpy, "WM_PROTOCOLS", True) && + (Atom)ev->xclient.data.l[0] == XInternAtom(dpy, "WM_DELETE_WINDOW", True)) { + if (!__atomic_exchange_n(&g_close_requested, true, __ATOMIC_RELAXED)) + log_info(LOGGER_TAG, "Window closed by the user, stopping"); + } +} + +static void send_wm_state(Display* dpy, int screen, Window win, bool add, const char* state) { + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xclient.type = ClientMessage; + ev.xclient.window = win; + ev.xclient.message_type = XInternAtom(dpy, "_NET_WM_STATE", False); + ev.xclient.format = 32; + ev.xclient.data.l[0] = add ? 1 : 0; /* _NET_WM_STATE_ADD / _REMOVE */ + ev.xclient.data.l[1] = (long)XInternAtom(dpy, state, False); + ev.xclient.data.l[3] = 1; + XSendEvent(dpy, RootWindow(dpy, screen), False, + SubstructureRedirectMask | SubstructureNotifyMask, &ev); +} + +/* Windowed mode keeps the wall position and size; only the title bar is added. */ +static void windowed_geometry(const struct x11_display_ctx* d, int* x, int* y, + uint32_t* w, uint32_t* h) { + *x = d->mon_x + d->out_x; + *y = d->mon_y + d->out_y; + *w = d->home_w; + *h = d->home_h; +} + +/* Caller holds g_wall_lock. */ +static void enter_windowed(struct x11_display_ctx* d) { + Display* dpy = (Display*)d->display; + Window win = (Window)(uintptr_t)d->window; + int x, y; + uint32_t w, h; + windowed_geometry(d, &x, &y, &w, &h); + + pthread_mutex_lock(&d->xlock); + if (d->wall_kind == X11_WALL_FULLSCREEN) { + send_wm_state(dpy, d->screen, win, false, "_NET_WM_STATE_FULLSCREEN"); + set_decorations(dpy, win, true); + set_position_hints(dpy, win, x, y, w, h); + XMoveResizeWindow(dpy, win, x, y, w, h); + } else { + /* An override-redirect window must be unmapped to become WM-managed. */ + XUnmapWindow(dpy, win); + XSetWindowAttributes attrs; + attrs.override_redirect = False; + XChangeWindowAttributes(dpy, win, CWOverrideRedirect, &attrs); + set_decorations(dpy, win, true); + set_position_hints(dpy, win, x, y, w, h); + XMoveResizeWindow(dpy, win, x, y, w, h); + XMapRaised(dpy, win); + } + d->windowed = true; + XFlush(dpy); + pthread_mutex_unlock(&d->xlock); +} + +/* Second half of leaving windowed mode for a fixed window, once the WM has + * released it (or after a timeout). Caller holds g_wall_lock. */ +static void finish_fixed_wall(struct x11_display_ctx* d) { + Display* dpy = (Display*)d->display; + Window win = (Window)(uintptr_t)d->window; + Window root = RootWindow(dpy, d->screen), parent = root, rret, *kids = NULL; + unsigned int nkids = 0; + int hx = d->mon_x + d->out_x, hy = d->mon_y + d->out_y; + + pthread_mutex_lock(&d->xlock); + if (XQueryTree(dpy, win, &rret, &parent, &kids, &nkids) && kids) XFree(kids); + if (parent != root) XReparentWindow(dpy, win, root, hx, hy); + XSetWindowAttributes attrs; + attrs.override_redirect = True; + XChangeWindowAttributes(dpy, win, CWOverrideRedirect, &attrs); + XMoveResizeWindow(dpy, win, hx, hy, d->home_w, d->home_h); + XMapRaised(dpy, win); + XFlush(dpy); + pthread_mutex_unlock(&d->xlock); + d->wall_pending = false; + d->windowed = false; +} + +/* Caller holds g_wall_lock. */ +static void leave_windowed(struct x11_display_ctx* d) { + Display* dpy = (Display*)d->display; + Window win = (Window)(uintptr_t)d->window; + + pthread_mutex_lock(&d->xlock); + if (d->wall_kind == X11_WALL_FULLSCREEN) { + set_decorations(dpy, win, false); + XMapRaised(dpy, win); /* un-minimize */ + send_wm_state(dpy, d->screen, win, true, "_NET_WM_STATE_FULLSCREEN"); + pin_fullscreen_to_monitor(dpy, d->screen, win, d->monitor_idx); + d->windowed = false; + } else { + /* Withdraw (also works when minimized); the WM reparents it back to the + * root and finish_fixed_wall() makes it override-redirect again. */ + XWithdrawWindow(dpy, win, d->screen); + d->wall_pending = true; + d->pending_ms = now_ms(); + } + XFlush(dpy); + pthread_mutex_unlock(&d->xlock); +} + +static void toggle_wall_mode(void) { + pthread_mutex_lock(&g_wall_lock); + for (int i = 0; i < g_nwall; i++) { + if (g_wall[i]->wall_pending) { /* previous toggle still in progress */ + pthread_mutex_unlock(&g_wall_lock); + return; + } + } + g_all_windowed = !g_all_windowed; + for (int i = 0; i < g_nwall; i++) { + if (g_all_windowed) enter_windowed(g_wall[i]); + else leave_windowed(g_wall[i]); + } + int n = g_nwall; + pthread_mutex_unlock(&g_wall_lock); + log_info(LOGGER_TAG, "Escape: %d window(s) %s", n, + g_all_windowed ? "switched to normal windows (Escape again to go back)" + : "back to full-screen wall mode"); +} + +/* True only for a fresh Escape press, not keyboard auto-repeat. The 1 s timeout + * recovers if a KeyRelease is ever missed. */ +static bool escape_pressed(void) { + long long now = now_ms(); + pthread_mutex_lock(&g_wall_lock); + bool fresh = !g_esc_down || now - g_esc_last_ms > 1000; + g_esc_down = true; + g_esc_last_ms = now; + pthread_mutex_unlock(&g_wall_lock); + return fresh; +} + +static void escape_released(void) { + pthread_mutex_lock(&g_wall_lock); + g_esc_down = false; + pthread_mutex_unlock(&g_wall_lock); +} + +/* Grab Escape on the root window so it works whichever window has focus. */ +static void grab_escape(struct x11_display_ctx* d) { + Display* dpy = (Display*)d->display; + Window root = RootWindow(dpy, d->screen); + KeyCode kc = XKeysymToKeycode(dpy, XK_Escape); + /* Also grab with Caps Lock / Num Lock on, or Escape is missed while they are lit. */ + unsigned int mods[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask}; + for (size_t i = 0; i < sizeof(mods) / sizeof(mods[0]); i++) + XGrabKey(dpy, kc, mods[i], root, True, GrabModeAsync, GrabModeAsync); + XFlush(dpy); + log_info(LOGGER_TAG, "Escape toggles the title bars from any window"); +} + +static void wall_register(struct x11_display_ctx* d) { + pthread_mutex_lock(&g_wall_lock); + if (g_nwall < MAX_WALL_WINDOWS) g_wall[g_nwall++] = d; + bool grab = !g_esc_grabbed; + g_esc_grabbed = true; + pthread_mutex_unlock(&g_wall_lock); + if (grab) grab_escape(d); +} + +static void wall_unregister(struct x11_display_ctx* d) { + pthread_mutex_lock(&g_wall_lock); + for (int i = 0; i < g_nwall; i++) { + if (g_wall[i] == d) { + g_wall[i] = g_wall[--g_nwall]; + break; + } + } + if (g_nwall == 0) g_all_windowed = false; + pthread_mutex_unlock(&g_wall_lock); +} + +/* Initialize X11 display */ +int init_x11_display(struct x11_display_ctx* display, uint32_t width, uint32_t height, + int session_idx, const char* output_name, bool fullscreen_on_output, + const struct crop_config* window_rect) { + Display* x_display; + Window x_window; + GC x_gc; + Visual* x_visual; + char window_title[96]; + int win_x = 100 + (session_idx * 50); + int win_y = 100 + (session_idx * 50); + int monitor_idx = -1; + char matched_output[32] = ""; + bool fixed_window = false; + + // Initialize structure + memset(display, 0, sizeof(*display)); + pthread_mutex_init(&display->xlock, NULL); + + // Check DISPLAY environment variable + const char* display_env = getenv("DISPLAY"); + if (!display_env) { + log_error(LOGGER_TAG, "DISPLAY environment variable not set. X11 display not available."); + log_info(LOGGER_TAG, "Hint: Run 'export DISPLAY=:0' or use SSH with X11 forwarding (-X flag)"); + return -1; + } + + // Set up X11 error handlers + XSetErrorHandler(x11_error_handler); + XSetIOErrorHandler(x11_io_error_handler); + + // Open X11 display connection + x_display = XOpenDisplay(NULL); + if (!x_display) { + log_error(LOGGER_TAG, "Cannot open X11 display '%s'", display_env); + log_info(LOGGER_TAG, "Hint: Check if X11 server is running or use SSH with X11 forwarding"); + return -1; + } + + display->display = x_display; + display->screen = DefaultScreen(x_display); + display->depth = DefaultDepth(x_display, display->screen); + /* Held keys repeat KeyPress only, without a KeyRelease between repeats. */ + XkbSetDetectableAutoRepeat(x_display, True, NULL); + display->frame_width = width; + display->frame_height = height; + display->frame_scratch = NULL; + + if (fullscreen_on_output) { + int mx = 0, my = 0, mw = 0, mh = 0; + if (find_output_geometry(x_display, RootWindow(x_display, display->screen), + output_name, session_idx, &mx, &my, &mw, &mh, + &monitor_idx, matched_output, sizeof(matched_output)) == 0) { + display->mon_x = mx; + display->mon_y = my; + display->mon_w = mw; + display->mon_h = mh; + if (window_rect) { + win_x = mx + window_rect->x; + win_y = my + window_rect->y; + display->out_x = window_rect->x; + display->out_y = window_rect->y; + width = (uint32_t)window_rect->w; + height = (uint32_t)window_rect->h; + fixed_window = true; + if (window_rect->x + window_rect->w > mw || window_rect->y + window_rect->h > mh) { + log_warn(LOGGER_TAG, "Session %d: window %d,%d %dx%d extends beyond %s (%dx%d)", + session_idx, window_rect->x, window_rect->y, window_rect->w, + window_rect->h, matched_output, mw, mh); + } + } else { + win_x = mx; + win_y = my; + width = (uint32_t)mw; + height = (uint32_t)mh; + } + } else { + /* A fallback window would land on another session's panel. */ + log_warn(LOGGER_TAG, "Session %d: output %s not connected, no window will be shown", + session_idx, (output_name && output_name[0]) ? output_name : "(by index)"); + XCloseDisplay(x_display); + display->display = NULL; + return -1; + } + } + bool on_output = fullscreen_on_output; + fullscreen_on_output = on_output && !fixed_window; + + // Get visual info + x_visual = DefaultVisual(x_display, display->screen); + unsigned long black = BlackPixel(x_display, display->screen); + XVisualInfo argb; + bool use_argb = fixed_window && + XMatchVisualInfo(x_display, display->screen, 32, TrueColor, &argb); + if (use_argb) { + /* Mutter draws a drop shadow around fixed windows (a dark band where two sessions + * meet) but never around ARGB windows; every pixel we write has alpha 255. */ + x_visual = argb.visual; + display->depth = 32; + display->colormap = XCreateColormap(x_display, RootWindow(x_display, display->screen), + x_visual, AllocNone); + black = 0xFF000000UL; + } + display->visual = x_visual; + + // Create window + if (on_output) { + snprintf(window_title, sizeof(window_title), "dvledrx - Session %d on %s", + session_idx, matched_output); + } else { + snprintf(window_title, sizeof(window_title), "dvledrx - Session %d (%ux%u)", + session_idx, width, height); + } + + if (use_argb) { + XSetWindowAttributes wa; + wa.colormap = (Colormap)display->colormap; + wa.border_pixel = black; + wa.background_pixel = black; + x_window = XCreateWindow(x_display, RootWindow(x_display, display->screen), + win_x, win_y, width, height, 0, 32, InputOutput, x_visual, + CWColormap | CWBorderPixel | CWBackPixel, &wa); + } else { + x_window = XCreateSimpleWindow(x_display, + RootWindow(x_display, display->screen), + win_x, win_y, + width, height, + on_output ? 0 : 1, // border width + black, // border + black // background + ); + } + + if (!x_window) { + log_error(LOGGER_TAG, "Cannot create X11 window"); + if (display->colormap) XFreeColormap(x_display, (Colormap)display->colormap); + XCloseDisplay(x_display); + return -1; + } + + display->window = (void*)(uintptr_t)x_window; + + // Set window properties + XStoreName(x_display, x_window, window_title); + XSelectInput(x_display, x_window, + ExposureMask | KeyPressMask | KeyReleaseMask | StructureNotifyMask); + Atom wm_delete = XInternAtom(x_display, "WM_DELETE_WINDOW", False); + XSetWMProtocols(x_display, x_window, &wm_delete, 1); /* close button: ClientMessage, not a kill */ + if (fullscreen_on_output) { + set_fullscreen_hints(x_display, x_window, win_x, win_y, width, height); + } else if (fixed_window) { + /* Bypass the WM so it can't move the window under panels or resize it. */ + XSetWindowAttributes attrs; + attrs.override_redirect = True; + XChangeWindowAttributes(x_display, x_window, CWOverrideRedirect, &attrs); + } + + // Create graphics context + x_gc = XCreateGC(x_display, x_window, 0, NULL); + display->gc = x_gc; + + // Image buffer uses the real screen depth (24 or 32) with a 4 bytes/pixel stride. + display->use_shm = true; + if (create_image_buffer(display, width, height) < 0) { + XFreeGC(x_display, x_gc); + XDestroyWindow(x_display, x_window); + XCloseDisplay(x_display); + return -1; + } + + // Map window (make it visible) + XMapWindow(x_display, x_window); + if (fullscreen_on_output) { + pin_fullscreen_to_monitor(x_display, display->screen, x_window, monitor_idx); + } else if (fixed_window) { + XRaiseWindow(x_display, x_window); + } + XFlush(x_display); + + display->initialized = true; + snprintf(display->output_name, sizeof(display->output_name), "%s", + on_output ? matched_output : "(window)"); + display->monitor_idx = monitor_idx; + display->home_w = width; + display->home_h = height; + display->wall_kind = fullscreen_on_output ? X11_WALL_FULLSCREEN + : fixed_window ? X11_WALL_FIXED + : X11_WALL_NONE; + if (display->wall_kind != X11_WALL_NONE) wall_register(display); + + log_info(LOGGER_TAG, "X11 display initialized: %ux%u+%d+%d, depth=%d, session=%d, output=%s (%s), shm=%s", + width, height, win_x, win_y, display->depth, session_idx, + on_output ? matched_output : "(window)", + fixed_window ? "fixed" : fullscreen_on_output ? "fullscreen" : "normal", + display->shminfo ? "yes" : "no"); + + return 0; +} + +/* Cleanup X11 display */ +void cleanup_x11_display(struct x11_display_ctx* display) { + if (!display || !display->initialized) { + return; + } + + Display* x_display = (Display*)display->display; + Window x_window = (Window)(uintptr_t)display->window; + GC x_gc = (GC)display->gc; + + wall_unregister(display); + destroy_image_buffer(x_display, (XImage*)display->ximage, display->image_data, + (XShmSegmentInfo*)display->shminfo); + + if (display->frame_scratch) { + free(display->frame_scratch); + } + + if (x_gc) { + XFreeGC(x_display, x_gc); + } + + if (x_window) { + XDestroyWindow(x_display, x_window); + } + + if (display->colormap) { + XFreeColormap(x_display, (Colormap)display->colormap); + } + + if (x_display) { + XCloseDisplay(x_display); + } + + memset(display, 0, sizeof(*display)); + + log_info(LOGGER_TAG, "X11 display cleaned up"); +} + +/* Resize only XImage buffer to match the current X11 window size. */ +static int resize_x11_buffers(struct x11_display_ctx* display, + uint32_t new_width, uint32_t new_height) { + if (!display || !display->initialized || !new_width || !new_height) return -1; + if (new_width == display->width && new_height == display->height) return 0; + + Display* x_display = (Display*)display->display; + XImage* old_image = (XImage*)display->ximage; + char* old_image_data = display->image_data; + XShmSegmentInfo* old_shm = (XShmSegmentInfo*)display->shminfo; + uint32_t old_w = display->width; + uint32_t old_h = display->height; + + if (create_image_buffer(display, new_width, new_height) < 0) { + display->ximage = old_image; + display->image_data = old_image_data; + display->shminfo = old_shm; + display->width = old_w; + display->height = old_h; + return -1; + } + + destroy_image_buffer(x_display, old_image, old_image_data, old_shm); + return 0; +} + +/* Nearest-neighbour scaler for BGRA buffers. */ +static void scale_bgra_nearest(const uint8_t* src, uint32_t src_w, uint32_t src_h, + uint8_t* dst, uint32_t dst_w, uint32_t dst_h) { + for (uint32_t y = 0; y < dst_h; y++) { + uint32_t sy = (uint32_t)(((uint64_t)y * src_h) / dst_h); + for (uint32_t x = 0; x < dst_w; x++) { + uint32_t sx = (uint32_t)(((uint64_t)x * src_w) / dst_w); + const uint8_t* sp = src + ((size_t)sy * src_w + sx) * 4; + uint8_t* dp = dst + ((size_t)y * dst_w + x) * 4; + dp[0] = sp[0]; + dp[1] = sp[1]; + dp[2] = sp[2]; + dp[3] = sp[3]; + } + } +} + +/* Display frame using X11 - uses unified convert_frame_to_rgb with proper linesize */ +void display_frame_x11(struct x11_display_ctx* display, struct st_frame* frame) { + if (!display || !display->initialized) { + return; + } + + Display* x_display = (Display*)display->display; + Window x_window = (Window)(uintptr_t)display->window; + GC x_gc = (GC)display->gc; + + int frame_counter = (int)++display->frame_counter; + + bool video_displayed = false; + + if (frame && frame->addr[0]) { + if (frame->width > 0 && frame->height > 0) { + display->frame_width = frame->width; + display->frame_height = frame->height; + } + + uint32_t frame_w = display->frame_width; + uint32_t frame_h = display->frame_height; + size_t scratch_size = (size_t)frame_w * frame_h * 4; + + if (display->frame_scratch_cap < scratch_size) { + free(display->frame_scratch); + display->frame_scratch = (char*)malloc(scratch_size); + display->frame_scratch_cap = display->frame_scratch ? scratch_size : 0; + if (!display->frame_scratch) { + log_error(LOGGER_TAG, "Cannot allocate frame scratch buffer (%ux%u)", frame_w, frame_h); + return; + } + } + /* ------------------------------------------------------- + * DIAGNOSTIC: log raw YUV plane info for first 5 frames + * to help identify green-screen root cause. + * ------------------------------------------------------- */ + if (frame_counter <= 5) { + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: fmt=%d %ux%u data_size=%zu", + frame_counter, frame->fmt, frame->width, frame->height, + frame->data_size); + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: addr Y=%p U=%p V=%p", + frame_counter, frame->addr[0], frame->addr[1], frame->addr[2]); + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: linesize Y=%zu U=%zu V=%zu", + frame_counter, frame->linesize[0], frame->linesize[1], + frame->linesize[2]); + + if ((frame->fmt == ST_FRAME_FMT_YUV422PLANAR10LE || + frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE) && + frame->addr[0] && frame->addr[1] && frame->addr[2]) { + int bit_shift = (frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE) ? 4 : 2; + uint16_t* yp = (uint16_t*)frame->addr[0]; + uint16_t* up = (uint16_t*)frame->addr[1]; + uint16_t* vp = (uint16_t*)frame->addr[2]; + size_t y_stride = frame->linesize[0] / 2; + size_t u_stride = frame->linesize[1] / 2; + + /* top-left pixel */ + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: TL raw16 Y=0x%04x U=0x%04x V=0x%04x " + "8-bit Y=%u U=%u V=%u", + frame_counter, + yp[0], up[0], vp[0], + (yp[0] >> bit_shift) & 0xFF, + (up[0] >> bit_shift) & 0xFF, + (vp[0] >> bit_shift) & 0xFF); + + /* centre pixel */ + uint32_t cx = frame_w / 2, cy = frame_h / 2; + uint16_t cy16 = yp[cy * y_stride + cx]; + uint16_t cu16 = up[cy * u_stride + cx / 2]; + uint16_t cv16 = vp[cy * u_stride + cx / 2]; + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: CTR raw16 Y=0x%04x U=0x%04x V=0x%04x " + "8-bit Y=%u U=%u V=%u", + frame_counter, + cy16, cu16, cv16, + (cy16 >> bit_shift) & 0xFF, + (cu16 >> bit_shift) & 0xFF, + (cv16 >> bit_shift) & 0xFF); + + /* Print first 4 raw U values to spot if U-plane is zero/garbage */ + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: U-plane first 4 words: 0x%04x 0x%04x 0x%04x 0x%04x", + frame_counter, up[0], up[1], up[2], up[3]); + log_debug(LOGGER_TAG, "DIAG[rx frame %d]: V-plane first 4 words: 0x%04x 0x%04x 0x%04x 0x%04x", + frame_counter, vp[0], vp[1], vp[2], vp[3]); + } + } + + /* ------------------------------------------------------- + * Convert using the unified converter that respects + * frame->linesize[], supporting all planar formats. + * The old convert_yuv422_to_rgb_safe used hardcoded stride + * (width/2) and would read wrong UV data if the frame had + * any row padding – producing all-green output. + * ------------------------------------------------------- */ + bool converted = false; + bool direct; + + /* Window size may change on the main thread (ConfigureNotify), so compare + * and write into image_data under the same lock. */ + pthread_mutex_lock(&display->xlock); + direct = (frame_w == display->width && frame_h == display->height); + if (direct) { + /* No scaling needed: convert straight into the X image buffer. */ + converted = (convert_frame_to_rgb(frame, (uint8_t*)display->image_data, frame_w, frame_h) == 0); + if (converted) put_image_locked(display); + } + pthread_mutex_unlock(&display->xlock); + + if (!direct) { + /* Convert only the sampled pixels, straight into the X image (no scratch pass). */ + pthread_mutex_lock(&display->xlock); + if (convert_frame_to_rgb_scaled(frame, (uint8_t*)display->image_data, + display->width, display->height) == 0) { + converted = true; + put_image_locked(display); + } + pthread_mutex_unlock(&display->xlock); + } + + if (!direct && !converted) { + converted = (convert_frame_to_rgb(frame, (uint8_t*)display->frame_scratch, frame_w, frame_h) == 0); + if (converted) { + pthread_mutex_lock(&display->xlock); + scale_bgra_nearest((uint8_t*)display->frame_scratch, frame_w, frame_h, + (uint8_t*)display->image_data, display->width, display->height); + put_image_locked(display); + pthread_mutex_unlock(&display->xlock); + } + } + + if (converted) { + video_displayed = true; + } else { + log_warn(LOGGER_TAG, "convert_frame_to_rgb failed " + "(fmt=%d, frame=%ux%u, win=%ux%u). Falling back.", + frame->fmt, frame_w, frame_h, + display->width, display->height); + } + } + + /* Fallback animated display when conversion is unavailable */ + if (!video_displayed) { + pthread_mutex_lock(&display->xlock); + uint32_t width = display->width; + uint32_t height = display->height; + unsigned long alpha = display->depth == 32 ? 0xFF000000UL : 0; + XSetForeground(x_display, x_gc, alpha | 0x404040); + XFillRectangle(x_display, x_window, x_gc, 0, 0, width, height); + XSetForeground(x_display, x_gc, alpha | (uint32_t)((frame_counter * 0x1234) & 0xFFFFFF)); + int rect_x = (frame_counter * 2) % (int)(width > 100 ? width - 100 : 1); + int rect_y = (frame_counter) % (int)(height > 100 ? height - 100 : 1); + XFillRectangle(x_display, x_window, x_gc, rect_x, rect_y, 100, 100); + XSetForeground(x_display, x_gc, alpha | 0xFFFFFF); + char status_text[96]; + if (frame && frame->addr[0]) + snprintf(status_text, sizeof(status_text), + "Converting frame %d (fmt=%d)...", frame_counter, + frame ? frame->fmt : -1); + else + snprintf(status_text, sizeof(status_text), "Waiting for video data..."); + XDrawString(x_display, x_window, x_gc, 10, 30, status_text, strlen(status_text)); + pthread_mutex_unlock(&display->xlock); + } + + pthread_mutex_lock(&display->xlock); + XFlush(x_display); + pthread_mutex_unlock(&display->xlock); +} + +/* Mirror src's already-converted image into dst (lock order: src, then dst). */ +bool display_copy_x11(struct x11_display_ctx* dst, struct x11_display_ctx* src) { + if (!dst || !src || !dst->initialized || !src->initialized) return false; + + bool copied = false; + pthread_mutex_lock(&src->xlock); + pthread_mutex_lock(&dst->xlock); + if (dst->width == src->width && dst->height == src->height && + dst->image_data && src->image_data) { + memcpy(dst->image_data, src->image_data, (size_t)dst->width * dst->height * 4); + put_image_locked(dst); + XFlush((Display*)dst->display); + copied = true; + } + pthread_mutex_unlock(&dst->xlock); + pthread_mutex_unlock(&src->xlock); + return copied; +} + +/* Process X11 events (non-blocking) */ +void process_x11_events(struct x11_display_ctx* display) { + if (!display || !display->initialized) { + return; + } + + // Safe, minimal event processing - only handle essential events + static int event_counter = 0; + + Display* x_display = (Display*)display->display; + + // Check if X11 connection is still valid + if (!x_display || XConnectionNumber(x_display) < 0) { + log_error(LOGGER_TAG, "X11 connection lost in process_x11_events"); + display->initialized = false; + return; + } + XEvent event; + + // Process events very carefully - avoid any operations that might corrupt protocol + int events_processed = 0; + + while (XPending(x_display) && events_processed < 10) { // Limit events per call + XNextEvent(x_display, &event); + events_processed++; + + switch (event.type) { + case Expose: + /* Do NOT XClearWindow here — it erases the last XPutImage frame. + * Re-blit the existing XImage instead so the window repaints + * correctly after being minimised / obscured. */ + pthread_mutex_lock(&display->xlock); + put_image_locked(display); + pthread_mutex_unlock(&display->xlock); + break; + + case KeyPress: + if (display->wall_kind != X11_WALL_NONE && + XLookupKeysym(&event.xkey, 0) == XK_Escape && escape_pressed()) { + toggle_wall_mode(); + } + break; + + case KeyRelease: + if (XLookupKeysym(&event.xkey, 0) == XK_Escape) escape_released(); + break; + + case ReparentNotify: + if (display->wall_pending && + event.xreparent.parent == RootWindow(x_display, display->screen)) { + pthread_mutex_lock(&g_wall_lock); + if (display->wall_pending) finish_fixed_wall(display); + pthread_mutex_unlock(&g_wall_lock); + } + break; + + case ClientMessage: + handle_close_message(x_display, &event); + break; + + case ConfigureNotify: + if (event.xconfigure.width > 0 && event.xconfigure.height > 0) { + pthread_mutex_lock(&display->xlock); + if (resize_x11_buffers(display, + (uint32_t)event.xconfigure.width, + (uint32_t)event.xconfigure.height) == 0 && + ++event_counter % 5 == 0) { + log_debug(LOGGER_TAG, "X11 window resized to %dx%d (event %d)", + event.xconfigure.width, event.xconfigure.height, event_counter); + } + pthread_mutex_unlock(&display->xlock); + } + break; + + default: + // Ignore all other events to avoid potential issues + break; + } + } + + // Simple flush without sync + if (events_processed > 0) { + XFlush(x_display); + } + + if (display->wall_pending && now_ms() - display->pending_ms > 1000) { + pthread_mutex_lock(&g_wall_lock); /* WM never released the window */ + if (display->wall_pending) finish_fixed_wall(display); + pthread_mutex_unlock(&g_wall_lock); + } +} + +/* ============================================================ + * Composite display implementation + * N session threads each write their strip then synchronise + * via a barrier; session 0 calls XPutImage once per frame. + * ============================================================ */ + +int init_composite_x11_display(struct composite_x11_ctx* comp, + uint32_t strip_width, uint32_t height, + int num_sessions) { + if (!comp || num_sessions < 1) return -1; + + memset(comp, 0, sizeof(*comp)); + + const char* display_env = getenv("DISPLAY"); + if (!display_env) { + log_error(LOGGER_TAG, "DISPLAY not set for composite display"); + return -1; + } + + XSetErrorHandler(x11_error_handler); + XSetIOErrorHandler(x11_io_error_handler); + + Display* x_display = XOpenDisplay(NULL); + if (!x_display) { + log_error(LOGGER_TAG, "Cannot open X11 display for composite"); + return -1; + } + + comp->display = x_display; + comp->screen = DefaultScreen(x_display); + comp->depth = DefaultDepth(x_display, comp->screen); + comp->strip_width = strip_width; + comp->full_width = strip_width; /* caller passes the total display width */ + comp->height = height; + comp->num_sessions = num_sessions; + comp->win_width = strip_width; /* on-screen size starts equal to native size */ + comp->win_height = height; + comp->scaled_ximage = NULL; + comp->scaled_image_data = NULL; + + Visual* x_visual = DefaultVisual(x_display, comp->screen); + comp->visual = x_visual; + + /* Full-width composite window */ + char title[128]; + snprintf(title, sizeof(title), + "dvledrx Composite (%dx%d, %d sessions)", + comp->full_width, height, num_sessions); + + Window x_window = XCreateSimpleWindow( + x_display, RootWindow(x_display, comp->screen), + 50, 50, comp->full_width, height, + 1, + BlackPixel(x_display, comp->screen), + BlackPixel(x_display, comp->screen)); + + if (!x_window) { + log_error(LOGGER_TAG, "Cannot create composite X11 window"); + XCloseDisplay(x_display); + return -1; + } + + comp->window = (void*)(uintptr_t)x_window; + + XStoreName(x_display, x_window, title); + XSelectInput(x_display, x_window, ExposureMask | KeyPressMask | StructureNotifyMask); + Atom wm_delete = XInternAtom(x_display, "WM_DELETE_WINDOW", False); + XSetWMProtocols(x_display, x_window, &wm_delete, 1); + + GC x_gc = XCreateGC(x_display, x_window, 0, NULL); + comp->gc = x_gc; + + /* Allocate composite pixel buffer */ + size_t buf_size = (size_t)comp->full_width * height * 4; + char* image_data = malloc(buf_size); + if (!image_data) { + log_error(LOGGER_TAG, "Cannot allocate composite image buffer (%zu bytes)", buf_size); + XFreeGC(x_display, x_gc); + XDestroyWindow(x_display, x_window); + XCloseDisplay(x_display); + return -1; + } + memset(image_data, 0, buf_size); + comp->image_data = image_data; + + XImage* x_image = XCreateImage(x_display, x_visual, + comp->depth, ZPixmap, 0, + image_data, + comp->full_width, height, + 32, (int)(comp->full_width * 4)); + if (!x_image) { + log_error(LOGGER_TAG, "Cannot create composite XImage"); + free(image_data); + XFreeGC(x_display, x_gc); + XDestroyWindow(x_display, x_window); + XCloseDisplay(x_display); + return -1; + } + comp->ximage = x_image; + + XMapWindow(x_display, x_window); + XFlush(x_display); + + /* Default-attribute mutex/cond init cannot fail on Linux. */ + pthread_mutex_init(&comp->bar_lock, NULL); + pthread_cond_init(&comp->bar_cond, NULL); + pthread_mutex_init(&comp->xlock, NULL); + + comp->initialized = true; + + log_info(LOGGER_TAG, "Composite X11 display initialised: %dx%d (%d sessions)", + comp->full_width, height, num_sessions); + return 0; +} + +void cleanup_composite_x11_display(struct composite_x11_ctx* comp) { + if (!comp || !comp->initialized) return; + + pthread_cond_destroy(&comp->bar_cond); + pthread_mutex_destroy(&comp->bar_lock); + pthread_mutex_destroy(&comp->xlock); + + Display* x_display = (Display*)comp->display; + GC x_gc = (GC)comp->gc; + Window x_window = (Window)(uintptr_t)comp->window; + XImage* x_image = (XImage*)comp->ximage; + XImage* scaled_image = (XImage*)comp->scaled_ximage; + + if (scaled_image) { + scaled_image->data = NULL; /* we own scaled_image_data; don't let XDestroyImage free it */ + XDestroyImage(scaled_image); + } + free(comp->scaled_image_data); + + if (x_image) { + x_image->data = NULL; /* We own image_data; don't let XDestroyImage free it */ + XDestroyImage(x_image); + } + free(comp->image_data); + if (x_gc) XFreeGC(x_display, x_gc); + if (x_window) XDestroyWindow(x_display, x_window); + if (x_display) XCloseDisplay(x_display); + + memset(comp, 0, sizeof(*comp)); + log_info(LOGGER_TAG, "Composite X11 display cleaned up"); +} + +/* Reallocate the window-sized scaled buffer to match new_w × new_h. + * Must be called with comp->xlock held. When new_w/new_h equal the native + * full_width/height, the scaled buffer is freed instead (XPutImage will use + * the native buffer directly). */ +static void resize_composite_scaled_buffer(struct composite_x11_ctx* comp, + uint32_t new_w, uint32_t new_h) { + if (!comp || !new_w || !new_h) return; + if (new_w == comp->win_width && new_h == comp->win_height) return; + + Display* x_display = (Display*)comp->display; + XImage* old_scaled = (XImage*)comp->scaled_ximage; + char* old_scaled_data = comp->scaled_image_data; + + comp->win_width = new_w; + comp->win_height = new_h; + + if (new_w == comp->full_width && new_h == comp->height) { + /* Back to native size: drop the scaled buffer, use comp->ximage directly */ + comp->scaled_ximage = NULL; + comp->scaled_image_data = NULL; + if (old_scaled) { + old_scaled->data = NULL; + XDestroyImage(old_scaled); + } + free(old_scaled_data); + return; + } + + size_t buf_size = (size_t)new_w * new_h * 4; + char* new_data = (char*)malloc(buf_size); + if (!new_data) { + log_error(LOGGER_TAG, "Cannot allocate scaled composite buffer (%ux%u)", new_w, new_h); + return; + } + memset(new_data, 0, buf_size); + + XImage* new_image = XCreateImage(x_display, (Visual*)comp->visual, + comp->depth, ZPixmap, 0, + new_data, new_w, new_h, + 32, (int)(new_w * 4)); + if (!new_image) { + log_error(LOGGER_TAG, "Cannot create scaled composite XImage (%ux%u)", new_w, new_h); + free(new_data); + return; + } + + comp->scaled_image_data = new_data; + comp->scaled_ximage = new_image; + + if (old_scaled) { + old_scaled->data = NULL; + XDestroyImage(old_scaled); + } + free(old_scaled_data); +} + +/* Blit the composite buffer to the window, scaling to the current window + * size if it differs from the native composite resolution (e.g. after the + * window manager fullscreens/resizes the window). Must be called with + * comp->xlock held. */ +static void composite_flush_locked(struct composite_x11_ctx* comp) { + Display* x_display = (Display*)comp->display; + Window x_window = (Window)(uintptr_t)comp->window; + GC x_gc = (GC)comp->gc; + + if (comp->scaled_ximage && + (comp->win_width != comp->full_width || comp->win_height != comp->height)) { + scale_bgra_nearest((uint8_t*)comp->image_data, comp->full_width, comp->height, + (uint8_t*)comp->scaled_image_data, comp->win_width, comp->win_height); + XPutImage(x_display, x_window, x_gc, (XImage*)comp->scaled_ximage, + 0, 0, 0, 0, comp->win_width, comp->win_height); + } else { + XPutImage(x_display, x_window, x_gc, (XImage*)comp->ximage, + 0, 0, 0, 0, comp->full_width, comp->height); + } + XFlush(x_display); +} + +/* Wait until every session has written its part of this frame. Returns true for + * exactly one caller per round (it flushes); false for the others or once aborted. */ +static bool composite_barrier_wait(struct composite_x11_ctx* comp) { + bool last = false; + pthread_mutex_lock(&comp->bar_lock); + if (!comp->bar_aborted) { + unsigned round = comp->bar_round; + if (++comp->bar_count == comp->num_sessions) { + comp->bar_count = 0; + comp->bar_round++; + pthread_cond_broadcast(&comp->bar_cond); + last = true; + } else { + while (round == comp->bar_round && !comp->bar_aborted) + pthread_cond_wait(&comp->bar_cond, &comp->bar_lock); + } + } + pthread_mutex_unlock(&comp->bar_lock); + return last; +} + +void composite_abort(struct composite_x11_ctx* comp) { + if (!comp || !comp->initialized) return; + pthread_mutex_lock(&comp->bar_lock); + comp->bar_aborted = true; + pthread_cond_broadcast(&comp->bar_cond); + pthread_mutex_unlock(&comp->bar_lock); +} + +void composite_write_strip(struct composite_x11_ctx* comp, + struct st_frame* frame, int session_idx) { + if (!comp || !comp->initialized || !frame) return; + + uint32_t strip_w = comp->strip_width; + uint32_t h = comp->height; + + /* Convert this session's strip to BGRA (4 bytes/pixel) into a temporary buffer */ + size_t strip_bytes = (size_t)strip_w * h * 4; + uint8_t* strip_buf = malloc(strip_bytes); + if (!strip_buf) { + log_error(LOGGER_TAG, "composite_write_strip: OOM for session %d", session_idx); + /* Still need to hit the barrier to avoid deadlock */ + composite_barrier_wait(comp); + return; + } + + convert_frame_to_rgb(frame, strip_buf, strip_w, h); + + /* Blit strip into composite buffer at the correct X offset. + * Each row in the composite buffer is full_width * 4 bytes wide. */ + uint32_t row_stride_comp = comp->full_width * 4; /* bytes per composite row */ + uint32_t row_stride_strip = strip_w * 4; /* bytes per strip row */ + uint32_t x_byte_off = (uint32_t)session_idx * strip_w * 4; /* byte offset in row */ + uint8_t* dst_base = (uint8_t*)comp->image_data; + + for (uint32_t row = 0; row < h; row++) { + uint8_t* dst = dst_base + row * row_stride_comp + x_byte_off; + uint8_t* src = strip_buf + row * row_stride_strip; + memcpy(dst, src, row_stride_strip); + } + + free(strip_buf); + + /* Synchronise: wait for all strips; the last one to arrive flushes. */ + if (composite_barrier_wait(comp)) { + pthread_mutex_lock(&comp->xlock); + composite_flush_locked(comp); + pthread_mutex_unlock(&comp->xlock); + } +} + +#include "config_reader.h" /* struct crop_config */ + +void composite_write_crop(struct composite_x11_ctx* comp, + struct st_frame* frame, + const struct crop_config* crop) { + if (!comp || !comp->initialized || !frame || !crop) return; + + uint32_t cw = (uint32_t)crop->w; + uint32_t ch = (uint32_t)crop->h; + + /* Convert this session's crop-sized frame to BGRA */ + size_t strip_bytes = (size_t)cw * ch * 4; + uint8_t* strip_buf = malloc(strip_bytes); + if (!strip_buf) { + log_error(LOGGER_TAG, "composite_write_crop: OOM (%ux%u)", cw, ch); + composite_barrier_wait(comp); + return; + } + + convert_frame_to_rgb(frame, strip_buf, cw, ch); + + /* Blit into the composite buffer at (crop->x, crop->y). + * Clamp to the composite dimensions to prevent out-of-bounds writes. */ + uint32_t dst_x = (uint32_t)crop->x; + uint32_t dst_y = (uint32_t)crop->y; + uint32_t row_stride_comp = comp->full_width * 4; + uint8_t* dst_base = (uint8_t*)comp->image_data; + + for (uint32_t row = 0; row < ch; row++) { + uint32_t dy = dst_y + row; + if (dy >= comp->height) break; + + uint32_t copy_w = cw; + if (dst_x + copy_w > comp->full_width) + copy_w = comp->full_width - dst_x; + + uint8_t* dst = dst_base + dy * row_stride_comp + dst_x * 4; + uint8_t* src = strip_buf + row * cw * 4; + memcpy(dst, src, copy_w * 4); + } + + free(strip_buf); + + /* Synchronise with other sessions */ + if (composite_barrier_wait(comp)) { + pthread_mutex_lock(&comp->xlock); + composite_flush_locked(comp); + pthread_mutex_unlock(&comp->xlock); + } +} + +void process_composite_x11_events(struct composite_x11_ctx* comp) { + if (!comp || !comp->initialized) return; + + Display* x_display = (Display*)comp->display; + + pthread_mutex_lock(&comp->xlock); + while (XPending(x_display) > 0) { + XEvent event; + XNextEvent(x_display, &event); + + if (event.type == Expose && event.xexpose.count == 0) { + /* Repaint composite image on expose */ + composite_flush_locked(comp); + } else if (event.type == ConfigureNotify) { + /* Window manager resized/fullscreened the window: rescale the + * composite buffer to fill the new window size, matching the + * behaviour of single-session windows. */ + if (event.xconfigure.width > 0 && event.xconfigure.height > 0) { + resize_composite_scaled_buffer(comp, + (uint32_t)event.xconfigure.width, + (uint32_t)event.xconfigure.height); + composite_flush_locked(comp); + } + } else if (event.type == ClientMessage) { + handle_close_message(x_display, &event); + } + } + pthread_mutex_unlock(&comp->xlock); +} diff --git a/dvledrx/tests/data/run_composite.json b/dvledrx/tests/data/run_composite.json new file mode 100644 index 0000000..c7bcac9 --- /dev/null +++ b/dvledrx/tests/data/run_composite.json @@ -0,0 +1,9 @@ +{ + "log_level": "loud", + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 64, "height": 16, "fps": 25, "fmt": "yuv444p10le" }, + "rx_sessions": [ + { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 32, "h": 16 } }, + { "udp_port": 20002, "crop": { "x": 32, "y": 0, "w": 32, "h": 16 } } + ] +} diff --git a/dvledrx/tests/data/run_fps24.json b/dvledrx/tests/data/run_fps24.json new file mode 100644 index 0000000..939b05d --- /dev/null +++ b/dvledrx/tests/data/run_fps24.json @@ -0,0 +1,5 @@ +{ + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 32, "height": 16, "fps": 24, "fmt": "yuv444p12le" }, + "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ] +} diff --git a/dvledrx/tests/data/run_fps29.json b/dvledrx/tests/data/run_fps29.json new file mode 100644 index 0000000..58a07a8 --- /dev/null +++ b/dvledrx/tests/data/run_fps29.json @@ -0,0 +1,5 @@ +{ + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 32, "height": 16, "fps": 29, "fmt": "gbrp10le" }, + "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ] +} diff --git a/dvledrx/tests/data/run_fps30.json b/dvledrx/tests/data/run_fps30.json new file mode 100644 index 0000000..b17afba --- /dev/null +++ b/dvledrx/tests/data/run_fps30.json @@ -0,0 +1,5 @@ +{ + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 32, "height": 16, "fps": 30 }, + "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ] +} diff --git a/dvledrx/tests/data/run_fps59.json b/dvledrx/tests/data/run_fps59.json new file mode 100644 index 0000000..eeef1cb --- /dev/null +++ b/dvledrx/tests/data/run_fps59.json @@ -0,0 +1,5 @@ +{ + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 32, "height": 16, "fps": 59, "fmt": "yuv420" }, + "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ] +} diff --git a/dvledrx/tests/data/run_per_output.json b/dvledrx/tests/data/run_per_output.json new file mode 100644 index 0000000..b5078f8 --- /dev/null +++ b/dvledrx/tests/data/run_per_output.json @@ -0,0 +1,18 @@ +{ + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 64, "height": 32, "fps": 50, "fmt": "yuv422p12le" }, + "rx_sessions": [ + { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 32, "h": 32 } }, + { "udp_port": 20002, "crop": { "x": 32, "y": 0, "w": 32, "h": 32 } } + ], + "displays": [ + { + "output": "screen", + "sessions": [ + { "session": 0, "x": 0, "y": 0, "scale_w": 16, "scale_h": 16 }, + { "session": 1, "x": 16, "y": 0, "scale_w": 16, "scale_h": 16 }, + { "session": 0, "x": 0, "y": 40, "scale_w": 16, "scale_h": 16 } + ] + } + ] +} diff --git a/dvledrx/tests/data/run_single.json b/dvledrx/tests/data/run_single.json new file mode 100644 index 0000000..b277bc4 --- /dev/null +++ b/dvledrx/tests/data/run_single.json @@ -0,0 +1,7 @@ +{ + "log_file": "dvledrx_fake_test.log", + "log_level": "debug", + "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ], + "video": { "width": 32, "height": 16, "fps": 60, "fmt": "gbrp12le" }, + "rx_sessions": [ { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 32, "h": 16 } } ] +} diff --git a/dvledrx/tests/fake_mtl.c b/dvledrx/tests/fake_mtl.c new file mode 100644 index 0000000..e634692 --- /dev/null +++ b/dvledrx/tests/fake_mtl.c @@ -0,0 +1,183 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + */ + +#include "fake_mtl.h" + +#include +#include +#include +#include +#include +#include + +#define POOL 3 + +struct fake_mtl_state fake_mtl; + +struct fake_rx { + struct st20p_rx_ops ops; + struct st_frame frames[POOL]; + bool in_use[POOL]; + int served; + bool woken; +}; + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_main_token; + +static int env_int(const char* name, int def) { + const char* v = getenv(name); + return v && v[0] ? atoi(v) : def; +} + +void fake_mtl_reset(void) { + memset(&fake_mtl, 0, sizeof(fake_mtl)); + fake_mtl.fail_init = env_int("FAKE_MTL_FAIL_INIT", 0) != 0; + fake_mtl.fail_create_at = env_int("FAKE_MTL_FAIL_CREATE", -1); + fake_mtl.frames_per_session = env_int("FAKE_MTL_FRAMES", -1); + fake_mtl.empty_every = env_int("FAKE_MTL_EMPTY_EVERY", 0); + fake_mtl.raise_after = env_int("FAKE_MTL_RAISE_AFTER", 0); +} + +__attribute__((constructor)) static void fake_mtl_ctor(void) { fake_mtl_reset(); } + +static bool is_planar16(enum st_frame_fmt f) { + return f == ST_FRAME_FMT_YUV422PLANAR10LE || f == ST_FRAME_FMT_YUV422PLANAR12LE || + f == ST_FRAME_FMT_YUV444PLANAR10LE || f == ST_FRAME_FMT_YUV444PLANAR12LE || + f == ST_FRAME_FMT_GBRPLANAR10LE || f == ST_FRAME_FMT_GBRPLANAR12LE; +} + +/* Allocate one frame of the session's output format, filled with a mid-level ramp. */ +static void frame_init(struct st_frame* f, enum st_frame_fmt fmt, uint32_t w, uint32_t h) { + memset(f, 0, sizeof(*f)); + f->fmt = fmt; + f->width = w; + f->height = h; + if (is_planar16(fmt)) { + bool sub = fmt == ST_FRAME_FMT_YUV422PLANAR10LE || fmt == ST_FRAME_FMT_YUV422PLANAR12LE; + for (int p = 0; p < 3; p++) { + uint32_t pw = (p > 0 && sub) ? w / 2 : w; + uint16_t* plane = malloc((size_t)pw * h * 2); + for (size_t i = 0; i < (size_t)pw * h; i++) plane[i] = (uint16_t)(256 + i % 256); + f->addr[p] = plane; + f->linesize[p] = (size_t)pw * 2; + f->data_size += (size_t)pw * h * 2; + } + } else if (fmt == ST_FRAME_FMT_YUV420PLANAR8) { + size_t sz[3] = {(size_t)w * h, (size_t)w * h / 4, (size_t)w * h / 4}; + for (int p = 0; p < 3; p++) { + f->addr[p] = calloc(1, sz[p] ? sz[p] : 1); + f->linesize[p] = p ? w / 2 : w; + f->data_size += sz[p]; + } + } else { + size_t sz = (size_t)w * h * 4; /* covers YUV420CUSTOM8 and packed formats */ + f->addr[0] = calloc(1, sz ? sz : 1); + f->linesize[0] = (size_t)w * 4; + f->data_size = sz; + } + f->buffer_size = f->data_size; +} + +mtl_handle mtl_init(struct mtl_init_params* p) { + fake_mtl.init_calls++; + if (p) fake_mtl.last_init = *p; + if (fake_mtl.fail_init) return NULL; + return (mtl_handle)&g_main_token; +} + +int mtl_uninit(mtl_handle mt) { + (void)mt; + fake_mtl.uninit_calls++; + return 0; +} + +enum mtl_pmd_type mtl_pmd_by_port_name(const char* port) { + (void)port; + return MTL_PMD_DPDK_USER; +} + +st20p_rx_handle st20p_rx_create(mtl_handle mt, struct st20p_rx_ops* ops) { + (void)mt; + int idx = fake_mtl.create_calls++; + if (idx < FAKE_MAX_RX) { + fake_mtl.ops[idx] = *ops; + snprintf(fake_mtl.names[idx], sizeof(fake_mtl.names[idx]), "%s", ops->name ? ops->name : ""); + fake_mtl.ops[idx].name = fake_mtl.names[idx]; + } + if (idx == fake_mtl.fail_create_at) return NULL; + + struct fake_rx* rx = calloc(1, sizeof(*rx)); + rx->ops = *ops; + for (int i = 0; i < POOL; i++) frame_init(&rx->frames[i], ops->output_fmt, ops->width, ops->height); + return (st20p_rx_handle)rx; +} + +size_t st20p_rx_frame_size(st20p_rx_handle handle) { + return ((struct fake_rx*)handle)->frames[0].data_size; +} + +struct st_frame* st20p_rx_get_frame(st20p_rx_handle handle) { + struct fake_rx* rx = (struct fake_rx*)handle; + struct st_frame* out = NULL; + bool do_raise = false; + + pthread_mutex_lock(&g_lock); + if (!rx->woken && + (fake_mtl.frames_per_session < 0 || rx->served < fake_mtl.frames_per_session)) { + for (int i = 0; i < POOL && !out; i++) { + if (rx->in_use[i]) continue; + rx->in_use[i] = true; + out = &rx->frames[i]; + } + } + if (out) { + rx->served++; + out->data_size = + (fake_mtl.empty_every > 0 && rx->served % fake_mtl.empty_every == 0) ? 0 : out->buffer_size; + fake_mtl.frames_got++; + do_raise = fake_mtl.raise_after > 0 && fake_mtl.frames_got == fake_mtl.raise_after; + } + pthread_mutex_unlock(&g_lock); + + if (do_raise) raise(SIGTERM); + return out; +} + +int st20p_rx_put_frame(st20p_rx_handle handle, struct st_frame* frame) { + struct fake_rx* rx = (struct fake_rx*)handle; + int ret = -1; + pthread_mutex_lock(&g_lock); + for (int i = 0; i < POOL; i++) { + if (frame == &rx->frames[i] && rx->in_use[i]) { + rx->in_use[i] = false; + fake_mtl.frames_put++; + ret = 0; + } + } + if (ret) fake_mtl.bad_puts++; + pthread_mutex_unlock(&g_lock); + return ret; +} + +int st20p_rx_wake_block(st20p_rx_handle handle) { + pthread_mutex_lock(&g_lock); + ((struct fake_rx*)handle)->woken = true; + fake_mtl.wake_calls++; + pthread_mutex_unlock(&g_lock); + return 0; +} + +int st20p_rx_free(st20p_rx_handle handle) { + struct fake_rx* rx = (struct fake_rx*)handle; + pthread_mutex_lock(&g_lock); + fake_mtl.free_calls++; + for (int i = 0; i < POOL; i++) { + if (rx->in_use[i]) fake_mtl.leaked++; + for (int p = 0; p < ST_MAX_PLANES; p++) free(rx->frames[i].addr[p]); + } + pthread_mutex_unlock(&g_lock); + free(rx); + return 0; +} diff --git a/dvledrx/tests/fake_mtl.h b/dvledrx/tests/fake_mtl.h new file mode 100644 index 0000000..6e8c493 --- /dev/null +++ b/dvledrx/tests/fake_mtl.h @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * In-process stand-in for the MTL calls dvledrx makes, so the session manager + * and main can run without a NIC, DPDK, hugepages or root. Sessions serve + * synthetic frames from a small pool and the fake records what it was asked. + */ + +#pragma once + +#include + +#include "mtl_api.h" +#include "st_pipeline_api.h" + +#define FAKE_MAX_RX 16 + +struct fake_mtl_state { + /* knobs (also settable via FAKE_MTL_* environment variables) */ + bool fail_init; /* FAKE_MTL_FAIL_INIT=1 */ + int fail_create_at; /* FAKE_MTL_FAIL_CREATE=: n-th create fails, -1 none */ + int frames_per_session; /* FAKE_MTL_FRAMES=: frames per session, -1 unlimited */ + int empty_every; /* FAKE_MTL_EMPTY_EVERY=: every n-th frame has no data */ + int raise_after; /* FAKE_MTL_RAISE_AFTER=: raise(SIGTERM) after n frames */ + + /* observations */ + int init_calls, uninit_calls, create_calls, free_calls, wake_calls; + int frames_got, frames_put, bad_puts, leaked; + struct mtl_init_params last_init; + struct st20p_rx_ops ops[FAKE_MAX_RX]; + char names[FAKE_MAX_RX][32]; /* ops[i].name points here */ +}; + +extern struct fake_mtl_state fake_mtl; + +/* Clear observations and reload the knobs from the environment. */ +void fake_mtl_reset(void); diff --git a/dvledrx/tests/meson.build b/dvledrx/tests/meson.build new file mode 100644 index 0000000..b6ad294 --- /dev/null +++ b/dvledrx/tests/meson.build @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2026 Intel Corporation + +rx_test_cc = meson.get_compiler('c') +rx_test_m_dep = rx_test_cc.find_library('m', required: true) +rx_test_pthread_dep = rx_test_cc.find_library('pthread', required: true) +rx_test_mtl_dep = dependency('mtl', required: true) +rx_test_x11_dep = dependency('x11', required: true) +rx_test_xext_dep = dependency('xext', required: true) +rx_test_xrandr_dep = dependency('xrandr', required: true) +rx_test_json_c_dep = dependency('json-c', required: true) +rx_test_zlib_dep = dependency('zlib', required: true) + +rx_test_c_args = [ + '-DALLOW_EXPERIMENTAL_API', + '-DDVLEDRX_VERSION="' + meson.project_version() + '"', + '-O3', + '-I/usr/local/include/mtl', +] +foreach arg : ['-march=native', '-funroll-loops'] + if rx_test_cc.has_argument(arg) + rx_test_c_args += [arg] + endif +endforeach + +rx_test_source_dir = meson.current_source_dir() / '..' +rx_test_config_dir = rx_test_source_dir / 'config' +rx_test_data_dir = meson.current_source_dir() / 'data' +rx_common_test_deps = [rx_test_m_dep, rx_test_pthread_dep, rx_test_x11_dep, + rx_test_xext_dep, rx_test_xrandr_dep, rx_test_json_c_dep, + rx_test_zlib_dep] + +rx_unit_tests = { + 'config_reader': [ + ['test_config_reader.c', '../src/config_reader.c', '../src/logger.c'], + [rx_test_json_c_dep, rx_test_zlib_dep, rx_test_pthread_dep], [rx_test_config_dir], + ], + 'logger': [ + ['test_logger.c', '../src/logger.c'], + [rx_test_zlib_dep, rx_test_pthread_dep], [], + ], + 'frame_converter': [ + ['test_frame_converter.c', '../src/frame_converter.c', '../src/logger.c'], + [rx_test_mtl_dep, rx_test_zlib_dep, rx_test_pthread_dep], [], + ], +} +foreach name, t : rx_unit_tests + test('dvledrx_' + name, + executable('test_dvledrx_' + name, t[0], + include_directories: include_directories('../include'), + dependencies: t[1], + c_args: rx_test_c_args, + build_by_default: false), + args: t[2], + suite: 'dvledrx-unit') +endforeach + +# These tests link fake_mtl.c instead of libmtl, so they need no NIC/DPDK hardware. +rx_mtl_headers = rx_test_mtl_dep.partial_dependency(compile_args: true, includes: true) +rx_app_test_deps = [rx_mtl_headers] + rx_common_test_deps +rx_app_test_srcs = [ + '../src/session_manager.c', '../src/x11_handler.c', '../src/frame_converter.c', + '../src/config_reader.c', '../src/logger.c', 'fake_mtl.c', +] +rx_app_test_inc = include_directories('../include', '.') + +rx_fake_app = executable('dvledrx_fake', ['../src/main.c'] + rx_app_test_srcs, + include_directories: rx_app_test_inc, + dependencies: rx_app_test_deps, + c_args: rx_test_c_args, + build_by_default: false) + +rx_cli_cases = { + 'cli_help': [['--help'], true], + 'cli_version': [['--version'], false], + 'cli_no_config': [[], true], + 'cli_config_missing': [['--config', 'does-not-exist.json'], true], + 'cli_config_invalid': [['-C', rx_test_source_dir / 'meson.build'], true], + 'cli_test_time_zero': [['-C', rx_test_data_dir / 'run_single.json', '-t', '0'], true], + 'cli_test_time_text': [['-C', rx_test_data_dir / 'run_single.json', '--test-time', '5s'], true], + 'cli_test_time_long': [['-C', rx_test_data_dir / 'run_single.json', '-t', '86401'], true], + 'cli_unknown_option': [['-C', rx_test_data_dir / 'run_single.json', '--bogus'], true], + 'cli_missing_value': [['-C'], true], +} +foreach name, c : rx_cli_cases + test('dvledrx_' + name, rx_fake_app, + args: c[0], should_fail: c[1], suite: 'dvledrx-unit') +endforeach + +# Window tests use a virtual X server; CI installs xvfb and xauth for these. +rx_xvfb_run = find_program('xvfb-run', required: false) +if rx_xvfb_run.found() + rx_xvfb_args = ['-a', '-s', '-screen 0 1280x720x24 -nolisten tcp'] + + foreach name, srcs : { + 'session_manager': ['test_session_manager.c'] + rx_app_test_srcs, + 'x11': ['test_x11.c', '../src/x11_handler.c', '../src/frame_converter.c', + '../src/config_reader.c', '../src/logger.c'], + } + test('dvledrx_' + name, rx_xvfb_run, + args: rx_xvfb_args + [executable('test_dvledrx_' + name, srcs, + include_directories: rx_app_test_inc, + dependencies: rx_app_test_deps, + c_args: rx_test_c_args, + build_by_default: false)], + suite: 'dvledrx-unit', timeout: 120) + endforeach + + rx_run_cases = { + 'run_single': ['run_single.json', ['-t', '1'], {}, false], + 'run_composite': ['run_composite.json', ['-t', '1'], {}, false], + 'run_per_output': ['run_per_output.json', ['-t', '1'], {}, false], + 'run_fps24': ['run_fps24.json', ['-t', '1'], {}, false], + 'run_fps29': ['run_fps29.json', ['-t', '1'], {}, false], + 'run_fps59': ['run_fps59.json', ['-t', '1'], {}, false], + 'run_sigterm': ['run_fps30.json', ['-t', '20'], {'FAKE_MTL_RAISE_AFTER': '20'}, false], + 'run_mtl_init_fail': ['run_single.json', [], {'FAKE_MTL_FAIL_INIT': '1'}, true], + 'run_create_fail': ['run_per_output.json', ['-t', '20'], {'FAKE_MTL_FAIL_CREATE': '1'}, true], + } + foreach name, r : rx_run_cases + test('dvledrx_' + name, rx_xvfb_run, + args: rx_xvfb_args + [rx_fake_app, '--config', rx_test_data_dir / r[0]] + r[1], + env: r[2], should_fail: r[3], suite: 'dvledrx-unit', timeout: 60) + endforeach +else + message('xvfb-run not found: receiver X11, session manager and app-run tests are skipped') +endif diff --git a/dvledrx/tests/test_common.h b/dvledrx/tests/test_common.h new file mode 100644 index 0000000..b831f06 --- /dev/null +++ b/dvledrx/tests/test_common.h @@ -0,0 +1,119 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * Minimal unit-test helpers (no external framework). Each test is a void + * function; CHECK* record a failure and continue, REQUIRE* stop the test. + */ + +#pragma once + +#define _XOPEN_SOURCE 700 +#define _DEFAULT_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +static int g_checks, g_failures, g_test_failed; +static const char* g_test_name; + +#define CHECK(cond) \ + do { \ + g_checks++; \ + if (!(cond)) { \ + g_failures++; \ + g_test_failed = 1; \ + fprintf(stderr, " FAIL %s:%d [%s]: %s\n", __FILE__, __LINE__, g_test_name, \ + #cond); \ + } \ + } while (0) + +#define CHECK_EQ_INT(a, b) \ + do { \ + long long a_ = (long long)(a), b_ = (long long)(b); \ + g_checks++; \ + if (a_ != b_) { \ + g_failures++; \ + g_test_failed = 1; \ + fprintf(stderr, " FAIL %s:%d [%s]: %s == %s (%lld != %lld)\n", __FILE__, \ + __LINE__, g_test_name, #a, #b, a_, b_); \ + } \ + } while (0) + +#define CHECK_EQ_STR(a, b) \ + do { \ + const char *a_ = (a), *b_ = (b); \ + g_checks++; \ + if (!a_ || !b_ || strcmp(a_, b_) != 0) { \ + g_failures++; \ + g_test_failed = 1; \ + fprintf(stderr, " FAIL %s:%d [%s]: %s == %s (\"%s\" != \"%s\")\n", __FILE__, \ + __LINE__, g_test_name, #a, #b, a_ ? a_ : "(null)", b_ ? b_ : "(null)"); \ + } \ + } while (0) + +#define REQUIRE(cond) \ + do { \ + int before_ = g_failures; \ + CHECK(cond); \ + if (g_failures != before_) return; \ + } while (0) + +#define RUN_TEST(fn) \ + do { \ + g_test_name = #fn; \ + g_test_failed = 0; \ + fn(); \ + fprintf(stderr, "%s %s\n", g_test_failed ? "FAIL" : "ok ", #fn); \ + } while (0) + +static int test_summary(void) { + fprintf(stderr, "%d checks, %d failure(s)\n", g_checks, g_failures); + return g_failures ? 1 : 0; +} + +/* Private (0700) scratch directory under the current directory; the process + * chdir()s into it so relative paths in tests stay inside it. The current + * directory must not be world-writable (the logger refuses e.g. /tmp). */ +static char g_scratch[PATH_MAX]; + +static void scratch_enter(const char* tag) { + char tmpl[64]; + snprintf(tmpl, sizeof(tmpl), "ut_%s_XXXXXX", tag); + if (!mkdtemp(tmpl) || !realpath(tmpl, g_scratch) || chdir(g_scratch) != 0) { + fprintf(stderr, "cannot create scratch dir: %s\n", strerror(errno)); + exit(2); + } +} + +static int rm_entry(const char* path, const struct stat* st, int flag, struct FTW* ftw) { + (void)st; (void)flag; (void)ftw; + return remove(path); +} + +static void scratch_leave(void) { + if (chdir("..") != 0) return; + if (nftw(g_scratch, rm_entry, 16, FTW_DEPTH | FTW_PHYS) != 0) + fprintf(stderr, "warning: could not remove %s\n", g_scratch); +} + +static inline int write_file(const char* path, const char* text) { + FILE* f = fopen(path, "w"); + if (!f) return -1; + fputs(text, f); + return fclose(f); +} + +static inline long file_size(const char* path) { + struct stat st; + return stat(path, &st) == 0 ? (long)st.st_size : -1; +} + +static inline int file_exists(const char* path) { + struct stat st; + return lstat(path, &st) == 0; +} diff --git a/dvledrx/tests/test_config_reader.c b/dvledrx/tests/test_config_reader.c new file mode 100644 index 0000000..de91b0d --- /dev/null +++ b/dvledrx/tests/test_config_reader.c @@ -0,0 +1,482 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * Unit tests for config_reader: JSON parsing, defaults, "displays" placement + * and every validate_rx_config() rule. + * Usage: test_config_reader [] + */ + +#include "test_common.h" + +#include + +#include "config_reader.h" +#include "logger.h" + +static const char* g_config_dir; + +/* Parse JSON text via a scratch file. */ +static int parse_text(const char* json, struct rx_config* cfg) { + if (write_file("cfg.json", json) != 0) return -99; + return parse_rx_config("cfg.json", cfg); +} + +/* A valid 2-NIC, 2-session config built in C; tests break one field each. */ +static void make_valid(struct rx_config* c) { + memset(c, 0, sizeof(*c)); + c->num_interfaces = 2; + for (int i = 0; i < 2; i++) { + c->interfaces[i].nic_index = i; + snprintf(c->interfaces[i].name, sizeof(c->interfaces[i].name), "0000:03:00.%d", i); + snprintf(c->interfaces[i].sip, sizeof(c->interfaces[i].sip), "192.168.50.%d", 65 + i); + snprintf(c->interfaces[i].dip, sizeof(c->interfaces[i].dip), "239.168.85.%d", 20 + i); + } + c->video.width = 1920; + c->video.height = 1080; + c->video.fps = 30; + snprintf(c->video.fmt, sizeof(c->video.fmt), "yuv422p10le"); + c->num_rx_sessions = 2; + for (int i = 0; i < 2; i++) { + struct rx_session_config* s = &c->rx_sessions[i]; + s->nic_index = i; + s->udp_port = 20000 + 2 * i; + s->payload_type = 96; + s->crop = (struct crop_config){i * 960, 0, 960, 1080}; + } +} + +#define EXPECT_INVALID(mutation) \ + do { \ + struct rx_config c_; \ + make_valid(&c_); \ + { struct rx_config* c = &c_; mutation; } \ + CHECK_EQ_INT(validate_rx_config(&c_), -1); \ + } while (0) + +#define EXPECT_VALID(mutation) \ + do { \ + struct rx_config c_; \ + make_valid(&c_); \ + { struct rx_config* c = &c_; mutation; } \ + CHECK_EQ_INT(validate_rx_config(&c_), 0); \ + } while (0) + +/* ------------------------------------------------------------------ parsing */ + +static void test_parse_full_config(void) { + struct rx_config c; + REQUIRE(parse_text( + "{ \"log_file\": \"rx.log\", \"log_level\": \"debug\", \"tasklet_sleep\": true,\n" + " \"interfaces\": [\n" + " { \"nic_index\": 3, \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.1.1.1\" },\n" + " { \"nic_index\": 7, \"name\": \"0000:03:00.1\", \"SIP\": \"192.168.50.66\", \"DIP\": \"239.1.1.2\" } ],\n" + " \"video\": { \"width\": 3840, \"height\": 2160, \"fps\": 60, \"fmt\": \"gbrp12le\" },\n" + " \"rx_sessions\": [\n" + " { \"nic_index\": 3, \"udp_port\": 30000, \"payload_type\": 112,\n" + " \"crop\": { \"x\": 0, \"y\": 0, \"w\": 1920, \"h\": 2160 } } ] }", &c) == 0); + CHECK_EQ_INT(c.num_interfaces, 2); + CHECK_EQ_INT(c.interfaces[0].nic_index, 3); + CHECK_EQ_INT(c.interfaces[1].nic_index, 7); + CHECK_EQ_STR(c.interfaces[0].name, "0000:03:00.0"); + CHECK_EQ_STR(c.interfaces[0].sip, "192.168.50.65"); + CHECK_EQ_STR(c.interfaces[1].sip, "192.168.50.66"); /* upper-case SIP/DIP accepted */ + CHECK_EQ_STR(c.interfaces[1].dip, "239.1.1.2"); + CHECK_EQ_INT(c.video.width, 3840); + CHECK_EQ_INT(c.video.height, 2160); + CHECK_EQ_INT(c.video.fps, 60); + CHECK_EQ_STR(c.video.fmt, "gbrp12le"); + CHECK_EQ_STR(c.log_file, "rx.log"); + CHECK_EQ_STR(c.log_level, "debug"); + CHECK(c.tasklet_sleep); + CHECK_EQ_INT(c.num_rx_sessions, 1); + CHECK_EQ_INT(c.rx_sessions[0].nic_index, 3); + CHECK_EQ_INT(c.rx_sessions[0].udp_port, 30000); + CHECK_EQ_INT(c.rx_sessions[0].payload_type, 112); + CHECK_EQ_INT(c.rx_sessions[0].crop.w, 1920); + CHECK_EQ_INT(c.rx_sessions[0].crop.h, 2160); + CHECK(!c.rx_sessions[0].has_window); + CHECK_EQ_STR(c.display_mode, ""); + CHECK_EQ_INT(validate_rx_config(&c), 0); +} + +static void test_parse_defaults(void) { + struct rx_config c; + REQUIRE(parse_text( + "{ \"interfaces\": [ { \"name\": \"a\" }, { \"name\": \"b\" } ],\n" + " \"video\": { \"width\": 1920, \"height\": 1080 },\n" + " \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 16 } } ] }", &c) == 0); + CHECK_EQ_INT(c.interfaces[0].nic_index, 0); /* nic_index defaults to position */ + CHECK_EQ_INT(c.interfaces[1].nic_index, 1); + CHECK_EQ_INT(c.video.fps, 30); + CHECK_EQ_STR(c.video.fmt, ""); + CHECK(!c.tasklet_sleep); + CHECK_EQ_INT(c.rx_sessions[0].nic_index, 0); + CHECK_EQ_INT(c.rx_sessions[0].udp_port, 20000); + CHECK_EQ_INT(c.rx_sessions[0].payload_type, 96); + CHECK_EQ_INT(c.rx_sessions[0].crop.x, 0); +} + +static void test_parse_video_size_from_crops(void) { + struct rx_config c; + REQUIRE(parse_text( + "{ \"video\": { \"fps\": 30 },\n" + " \"rx_sessions\": [ { \"crop\": { \"x\": 0, \"y\": 0, \"w\": 1920, \"h\": 2160 } },\n" + " { \"crop\": { \"x\": 1920, \"y\": 0, \"w\": 1920, \"h\": 2160 } } ] }", + &c) == 0); + CHECK_EQ_INT(c.video.width, 3840); + CHECK_EQ_INT(c.video.height, 2160); + + REQUIRE(parse_text("{ \"video\": { \"fps\": 30 } }", &c) == 0); + CHECK_EQ_INT(c.video.width, 1920); /* no sessions: full HD fallback */ + CHECK_EQ_INT(c.video.height, 1080); +} + +static void test_parse_displays_and_mirrors(void) { + struct rx_config c; + REQUIRE(parse_text( + "{ \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 960, \"h\": 1080 } },\n" + " { \"udp_port\": 20002, \"crop\": { \"x\": 960, \"w\": 960, \"h\": 1080 } } ],\n" + " \"displays\": [\n" + " { \"output\": \"DP-1\", \"sessions\": [\n" + " { \"session\": 0, \"x\": 0, \"y\": 0, \"scale_w\": 540, \"scale_h\": 1920 },\n" + " { \"session\": 1, \"x\": 540, \"y\": 0, \"scale_w\": 540, \"scale_h\": 1920 } ] },\n" + " { \"output\": \"HDMI-2\", \"sessions\": [\n" + " { \"session\": 1, \"x\": 10, \"y\": 20, \"w\": 300, \"h\": 400, \"scale_w\": 1080, \"scale_h\": 1920 } ] } ] }", + &c) == 0); + CHECK_EQ_STR(c.display_mode, "per_output"); /* implied by "displays" */ + const struct rx_session_config* s0 = &c.rx_sessions[0]; + const struct rx_session_config* s1 = &c.rx_sessions[1]; + CHECK(s0->display_assigned && s0->has_window); + CHECK_EQ_STR(s0->output, "DP-1"); + CHECK_EQ_INT(s0->scale_w, 540); + CHECK_EQ_INT(s0->num_mirrors, 0); + CHECK_EQ_STR(s1->output, "DP-1"); + CHECK_EQ_INT(s1->window.x, 540); + CHECK_EQ_INT(s1->num_mirrors, 1); /* second placement becomes a mirror */ + CHECK_EQ_STR(s1->mirrors[0].output, "HDMI-2"); + CHECK_EQ_INT(s1->mirrors[0].window.x, 10); + CHECK_EQ_INT(s1->mirrors[0].window.y, 20); + CHECK_EQ_INT(s1->mirrors[0].window.w, 300); + CHECK_EQ_INT(s1->mirrors[0].window.h, 400); + CHECK_EQ_INT(s1->mirrors[0].scale_w, 1080); + CHECK_EQ_INT(s1->mirrors[0].scale_h, 1920); +} + +static void test_parse_display_mode_not_overridden(void) { + struct rx_config c; + REQUIRE(parse_text("{ \"display_mode\": \"composite\", \"rx_sessions\": [ {} ],\n" + " \"displays\": [ { \"output\": \"DP-1\", \"sessions\": [ { \"session\": 0 } ] } ] }", + &c) == 0); + CHECK_EQ_STR(c.display_mode, "composite"); +} + +static void test_parse_display_unknown_session(void) { + struct rx_config c; + CHECK_EQ_INT(parse_text("{ \"rx_sessions\": [ {} ],\n" + " \"displays\": [ { \"output\": \"DP-1\", \"sessions\": [ { \"session\": 1 } ] } ] }", + &c), -1); + CHECK_EQ_INT(parse_text("{ \"rx_sessions\": [ {} ],\n" + " \"displays\": [ { \"output\": \"DP-1\", \"sessions\": [ { \"x\": 0 } ] } ] }", + &c), -1); +} + +static void test_parse_too_many_mirrors(void) { + char json[2048]; + struct rx_config c; + int n = snprintf(json, sizeof(json), "{ \"rx_sessions\": [ {} ], \"displays\": ["); + for (int d = 0; d < MAX_MIRRORS + 2; d++) + n += snprintf(json + n, sizeof(json) - n, + "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }", + d ? "," : "", d); + snprintf(json + n, sizeof(json) - n, " ] }"); + CHECK_EQ_INT(parse_text(json, &c), -1); + + /* exactly 1 primary + MAX_MIRRORS mirrors is fine */ + n = snprintf(json, sizeof(json), "{ \"rx_sessions\": [ {} ], \"displays\": ["); + for (int d = 0; d < MAX_MIRRORS + 1; d++) + n += snprintf(json + n, sizeof(json) - n, + "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }", + d ? "," : "", d); + snprintf(json + n, sizeof(json) - n, " ] }"); + REQUIRE(parse_text(json, &c) == 0); + CHECK_EQ_INT(c.rx_sessions[0].num_mirrors, MAX_MIRRORS); +} + +static void test_parse_session_window_and_scale(void) { + struct rx_config c; + REQUIRE(parse_text( + "{ \"rx_sessions\": [ { \"output\": \"HDMI-1\", \"crop\": { \"w\": 960, \"h\": 540 },\n" + " \"window\": { \"x\": 5, \"y\": 6, \"w\": 7, \"h\": 8 }, \"scale\": { \"w\": 1920, \"h\": 1080 } },\n" + " { \"window\": 1, \"scale\": \"2x\" } ] }", &c) == 0); + const struct rx_session_config* s = &c.rx_sessions[0]; + CHECK_EQ_STR(s->output, "HDMI-1"); + CHECK(s->has_window && !s->display_assigned); + CHECK(s->window.x == 5 && s->window.y == 6 && s->window.w == 7 && s->window.h == 8); + CHECK_EQ_INT(s->scale_w, 1920); + CHECK_EQ_INT(s->scale_h, 1080); + CHECK(!c.rx_sessions[1].has_window); /* non-object window/scale ignored */ + CHECK_EQ_INT(c.rx_sessions[1].scale_w, 0); +} + +static void test_parse_display_without_sessions_ignored(void) { + struct rx_config c; + REQUIRE(parse_text("{ \"rx_sessions\": [ {} ],\n" + " \"displays\": [ { \"output\": \"DP-1\" },\n" + " { \"output\": \"DP-2\", \"sessions\": {} },\n" + " { \"output\": \"HDMI-2\", \"sessions\": [ { \"session\": 0 } ] } ] }", + &c) == 0); + CHECK_EQ_STR(c.rx_sessions[0].output, "HDMI-2"); + CHECK_EQ_INT(c.rx_sessions[0].num_mirrors, 0); +} + +static void test_parse_truncates_arrays(void) { + char json[8192]; + struct rx_config c; + int n = snprintf(json, sizeof(json), "{ \"interfaces\": ["); + for (int i = 0; i < MAX_INTERFACES + 2; i++) + n += snprintf(json + n, sizeof(json) - n, "%s{ \"name\": \"n%d\" }", i ? "," : "", i); + n += snprintf(json + n, sizeof(json) - n, "], \"rx_sessions\": ["); + for (int i = 0; i < MAX_RX_SESSIONS + 3; i++) + n += snprintf(json + n, sizeof(json) - n, "%s{ \"udp_port\": %d }", i ? "," : "", 20000 + i); + snprintf(json + n, sizeof(json) - n, "] }"); + REQUIRE(parse_text(json, &c) == 0); + CHECK_EQ_INT(c.num_interfaces, MAX_INTERFACES); + CHECK_EQ_INT(c.num_rx_sessions, MAX_RX_SESSIONS); +} + +static void test_parse_long_strings_truncated(void) { + char json[512], name[200]; + struct rx_config c; + memset(name, 'x', sizeof(name) - 1); + name[sizeof(name) - 1] = '\0'; + snprintf(json, sizeof(json), "{ \"interfaces\": [ { \"name\": \"%s\" } ] }", name); + REQUIRE(parse_text(json, &c) == 0); + CHECK_EQ_INT(strlen(c.interfaces[0].name), sizeof(c.interfaces[0].name) - 1); +} + +static void test_parse_wrong_types_ignored(void) { + struct rx_config c; + REQUIRE(parse_text("{ \"video\": { \"width\": \"1920\", \"fps\": \"60\", \"fmt\": 5 },\n" + " \"rx_sessions\": [ { \"udp_port\": \"x\", \"crop\": [] } ] }", &c) == 0); + CHECK_EQ_INT(c.video.fps, 30); + CHECK_EQ_STR(c.video.fmt, ""); + CHECK_EQ_INT(c.rx_sessions[0].udp_port, 20000); + CHECK_EQ_INT(c.rx_sessions[0].crop.w, 0); +} + +static void test_parse_errors(void) { + struct rx_config c; + CHECK_EQ_INT(parse_text("{ \"interfaces\": [ ", &c), -1); + CHECK_EQ_INT(parse_rx_config("does-not-exist.json", &c), -1); + CHECK_EQ_INT(parse_rx_config(NULL, &c), -1); + CHECK_EQ_INT(parse_rx_config("cfg.json", NULL), -1); + CHECK_EQ_INT(load_and_validate_rx_config("", &c), -1); + CHECK_EQ_INT(load_and_validate_rx_config(NULL, &c), -1); + CHECK_EQ_INT(validate_rx_config(NULL), -1); +} + +static void test_load_and_validate(void) { + struct rx_config c; + REQUIRE(write_file("ok.json", + "{ \"interfaces\": [ { \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.168.85.20\" } ],\n" + " \"video\": { \"fps\": 30, \"fmt\": \"yuv422p10le\" },\n" + " \"rx_sessions\": [ { \"crop\": { \"w\": 1920, \"h\": 1080 } } ] }") == 0); + CHECK_EQ_INT(load_and_validate_rx_config("ok.json", &c), 0); + REQUIRE(write_file("bad.json", + "{ \"interfaces\": [ { \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\" } ],\n" + " \"video\": { \"fps\": 61 }, \"rx_sessions\": [ { \"crop\": { \"w\": 8, \"h\": 8 } } ] }") == 0); + CHECK_EQ_INT(load_and_validate_rx_config("bad.json", &c), -1); +} + +/* --------------------------------------------------------------- validation */ + +static void test_validate_baseline(void) { + EXPECT_VALID((void)c); + EXPECT_VALID(c->video.fmt[0] = '\0'); /* empty fmt = yuv422p10le */ + EXPECT_VALID(c->rx_sessions[1].udp_port = 20000); /* same port on another NIC */ +} + +static void test_validate_interfaces(void) { + EXPECT_INVALID(c->num_interfaces = 0); + EXPECT_INVALID(c->interfaces[1].name[0] = '\0'); + EXPECT_INVALID(snprintf(c->interfaces[0].sip, sizeof(c->interfaces[0].sip), "192.168.50")); + EXPECT_INVALID(c->interfaces[0].sip[0] = '\0'); + EXPECT_INVALID(c->interfaces[0].dip[0] = '\0'); + EXPECT_INVALID(snprintf(c->interfaces[0].dip, sizeof(c->interfaces[0].dip), "239.1.1.256")); + EXPECT_INVALID(c->interfaces[1].nic_index = 0); + EXPECT_INVALID(snprintf(c->interfaces[1].name, sizeof(c->interfaces[1].name), "0000:03:00.0")); +} + +static void test_validate_video_size(void) { + EXPECT_VALID((c->video.width = 3840, c->video.height = 2160, + c->rx_sessions[1].crop.x = 2880)); + EXPECT_VALID((c->video.width = 2160, c->video.height = 3840)); /* portrait 4K */ + EXPECT_INVALID(c->video.width = 0); + EXPECT_INVALID(c->video.height = -1); + EXPECT_INVALID(c->video.width = 3841); + EXPECT_INVALID((c->video.width = 3840, c->video.height = 3840)); /* > 4K pixels */ + EXPECT_INVALID((c->video.width = 4096, c->video.height = 2160)); +} + +static void test_validate_fps(void) { + static const int ok[] = {24, 25, 29, 30, 50, 59, 60}; + static const int bad[] = {0, -30, 1, 23, 31, 45, 61, 120}; + for (size_t i = 0; i < sizeof(ok) / sizeof(ok[0]); i++) EXPECT_VALID(c->video.fps = ok[i]); + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) EXPECT_INVALID(c->video.fps = bad[i]); +} + +static void test_validate_fmt(void) { + static const char* ok[] = {"yuv422p10le", "yuv422p12le", "yuv444p10le", "yuv444p12le", + "gbrp10le", "gbrp12le", "yuv420"}; + static const char* bad[] = {"nv12", "YUV422P10LE", "yuv422p10", "rgb24", " "}; + for (size_t i = 0; i < sizeof(ok) / sizeof(ok[0]); i++) + EXPECT_VALID(snprintf(c->video.fmt, sizeof(c->video.fmt), "%s", ok[i])); + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) + EXPECT_INVALID(snprintf(c->video.fmt, sizeof(c->video.fmt), "%s", bad[i])); +} + +static void test_validate_display_mode(void) { + EXPECT_VALID(snprintf(c->display_mode, sizeof(c->display_mode), "composite")); + EXPECT_VALID(snprintf(c->display_mode, sizeof(c->display_mode), "per_output")); + EXPECT_INVALID(snprintf(c->display_mode, sizeof(c->display_mode), "tiled")); +} + +static void test_validate_sessions(void) { + EXPECT_INVALID(c->num_rx_sessions = 0); + EXPECT_INVALID(c->rx_sessions[0].crop.w = 0); + EXPECT_INVALID(c->rx_sessions[0].crop.h = -4); + EXPECT_INVALID(c->rx_sessions[0].crop.x = -1); + EXPECT_INVALID(c->rx_sessions[0].crop.y = -1); + EXPECT_INVALID(c->rx_sessions[1].crop.x = 961); /* x + w > width */ + EXPECT_INVALID(c->rx_sessions[1].crop.y = 1); /* y + h > height */ + EXPECT_VALID(c->rx_sessions[1].udp_port = 65535); + EXPECT_VALID(c->rx_sessions[1].udp_port = 1); + EXPECT_INVALID(c->rx_sessions[1].udp_port = 0); + EXPECT_INVALID(c->rx_sessions[1].udp_port = 65536); + EXPECT_VALID(c->rx_sessions[1].payload_type = 127); + EXPECT_INVALID(c->rx_sessions[1].payload_type = 95); + EXPECT_INVALID(c->rx_sessions[1].payload_type = 128); + EXPECT_INVALID((c->rx_sessions[1].nic_index = 0, c->rx_sessions[1].udp_port = 20000)); + EXPECT_INVALID(c->rx_sessions[1].nic_index = 5); /* no interface with nic_index 5 */ +} + +static void test_validate_windows(void) { + EXPECT_VALID((c->rx_sessions[0].has_window = true, + c->rx_sessions[0].window = (struct crop_config){0, 0, 540, 1920})); + EXPECT_INVALID((c->rx_sessions[0].has_window = true, c->rx_sessions[0].window.x = -1)); + EXPECT_INVALID((c->rx_sessions[0].has_window = true, c->rx_sessions[0].window.w = 3841)); + EXPECT_VALID((c->rx_sessions[0].scale_w = 3840, c->rx_sessions[0].scale_h = 2160)); + EXPECT_INVALID(c->rx_sessions[0].scale_w = 540); /* scale_h missing */ + EXPECT_INVALID(c->rx_sessions[0].scale_h = 540); + EXPECT_INVALID((c->rx_sessions[0].scale_w = 3841, c->rx_sessions[0].scale_h = 100)); + EXPECT_INVALID((c->rx_sessions[0].scale_w = -1, c->rx_sessions[0].scale_h = -1)); + /* placed by "displays" but with no output name */ + EXPECT_INVALID(c->rx_sessions[0].display_assigned = true); + EXPECT_VALID((c->rx_sessions[0].display_assigned = true, + snprintf(c->rx_sessions[0].output, sizeof(c->rx_sessions[0].output), "DP-1"))); +} + +static void test_validate_mirrors(void) { +#define ADD_MIRROR(out, sw, sh) \ + do { \ + struct display_placement* m_ = &c->rx_sessions[0].mirrors[c->rx_sessions[0].num_mirrors++]; \ + snprintf(m_->output, sizeof(m_->output), "%s", out); \ + m_->scale_w = sw; \ + m_->scale_h = sh; \ + } while (0) + EXPECT_VALID(ADD_MIRROR("HDMI-2", 1080, 1920)); + EXPECT_INVALID(ADD_MIRROR("", 1080, 1920)); + EXPECT_INVALID(ADD_MIRROR("HDMI-2", 1080, 0)); + EXPECT_INVALID(ADD_MIRROR("HDMI-2", 4000, 1920)); + EXPECT_INVALID(ADD_MIRROR("HDMI-2", 0, 0); c->rx_sessions[0].mirrors[0].window.y = -5); +#undef ADD_MIRROR +} + +static void test_validate_bandwidth_is_warning_only(void) { + /* 4K60 12-bit 4:4:4 on one NIC is ~18.8 Gb/s: warned about, not rejected */ + EXPECT_VALID((c->video.width = 3840, c->video.height = 2160, c->video.fps = 60, + snprintf(c->video.fmt, sizeof(c->video.fmt), "gbrp12le"), + c->num_rx_sessions = 1, + c->rx_sessions[0].crop = (struct crop_config){0, 0, 3840, 2160})); +} + +static void test_validate_log_file(void) { + EXPECT_VALID(snprintf(c->log_file, sizeof(c->log_file), "rx.log")); + EXPECT_INVALID(snprintf(c->log_file, sizeof(c->log_file), "/tmp/rx.log")); + EXPECT_INVALID(snprintf(c->log_file, sizeof(c->log_file), "missing-dir/rx.log")); + EXPECT_INVALID(snprintf(c->log_file, sizeof(c->log_file), "logs/")); +} + +static void test_port_for_nic(void) { + struct rx_config c; + make_valid(&c); + c.interfaces[0].nic_index = 4; + c.interfaces[1].nic_index = 2; + CHECK_EQ_INT(rx_config_port_for_nic(&c, 4), 0); + CHECK_EQ_INT(rx_config_port_for_nic(&c, 2), 1); + CHECK_EQ_INT(rx_config_port_for_nic(&c, 0), -1); + CHECK_EQ_INT(rx_config_port_for_nic(&c, -1), -1); +} + +/* Every shipped config under config/ must load and validate. */ +static void test_repo_configs_valid(void) { + if (!g_config_dir) { + fprintf(stderr, " (skipped: no config dir argument)\n"); + return; + } + DIR* d = opendir(g_config_dir); + REQUIRE(d != NULL); + int n = 0; + struct dirent* e; + while ((e = readdir(d)) != NULL) { + size_t len = strlen(e->d_name); + if (len < 5 || strcmp(e->d_name + len - 5, ".json") != 0) continue; + char path[PATH_MAX]; + struct rx_config c; + snprintf(path, sizeof(path), "%s/%s", g_config_dir, e->d_name); + int rc = load_and_validate_rx_config(path, &c); + if (rc != 0) fprintf(stderr, " %s is invalid\n", e->d_name); + CHECK_EQ_INT(rc, 0); + n++; + } + closedir(d); + CHECK(n > 0); +} + +int main(int argc, char** argv) { + char dir[PATH_MAX]; + if (argc > 1 && realpath(argv[1], dir)) g_config_dir = dir; + logger_set_level(LOG_LEVEL_ERROR + 1); /* keep expected-error noise out of the output */ + scratch_enter("config"); + + RUN_TEST(test_parse_full_config); + RUN_TEST(test_parse_defaults); + RUN_TEST(test_parse_video_size_from_crops); + RUN_TEST(test_parse_displays_and_mirrors); + RUN_TEST(test_parse_display_mode_not_overridden); + RUN_TEST(test_parse_display_unknown_session); + RUN_TEST(test_parse_too_many_mirrors); + RUN_TEST(test_parse_session_window_and_scale); + RUN_TEST(test_parse_display_without_sessions_ignored); + RUN_TEST(test_parse_truncates_arrays); + RUN_TEST(test_parse_long_strings_truncated); + RUN_TEST(test_parse_wrong_types_ignored); + RUN_TEST(test_parse_errors); + RUN_TEST(test_load_and_validate); + RUN_TEST(test_validate_baseline); + RUN_TEST(test_validate_interfaces); + RUN_TEST(test_validate_video_size); + RUN_TEST(test_validate_fps); + RUN_TEST(test_validate_fmt); + RUN_TEST(test_validate_display_mode); + RUN_TEST(test_validate_sessions); + RUN_TEST(test_validate_windows); + RUN_TEST(test_validate_mirrors); + RUN_TEST(test_validate_bandwidth_is_warning_only); + RUN_TEST(test_validate_log_file); + RUN_TEST(test_port_for_nic); + RUN_TEST(test_repo_configs_valid); + + scratch_leave(); + return test_summary(); +} diff --git a/dvledrx/tests/test_frame_converter.c b/dvledrx/tests/test_frame_converter.c new file mode 100644 index 0000000..19914d4 --- /dev/null +++ b/dvledrx/tests/test_frame_converter.c @@ -0,0 +1,376 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * Unit tests for frame_converter: BT.601 math, every supported ST 2110 pixel + * format to BGRA (incl. padded line strides) and nearest-neighbour scaling. + */ + +#include "test_common.h" + +#include + +#include "frame_converter.h" +#include "logger.h" +#include "st_pipeline_api.h" + +/* ------------------------------------------------------------ test frames */ + +static uint32_t g_rand = 12345; +static uint32_t rnd(void) { + g_rand = g_rand * 1103515245u + 12345u; + return g_rand >> 8; +} + +static bool is_12bit(enum st_frame_fmt fmt) { + return fmt == ST_FRAME_FMT_YUV422PLANAR12LE || fmt == ST_FRAME_FMT_YUV444PLANAR12LE || + fmt == ST_FRAME_FMT_GBRPLANAR12LE; +} + +/* 16-bit planar frame with pad_px extra pixels per line; random samples at the + * format's bit depth, or every sample = value when value >= 0. */ +static void frame_alloc(struct st_frame* f, enum st_frame_fmt fmt, uint32_t w, uint32_t h, + uint32_t pad_px, int value) { + memset(f, 0, sizeof(*f)); + f->fmt = fmt; + f->width = w; + f->height = h; + uint16_t mask = is_12bit(fmt) ? 0xFFF : 0x3FF; + for (int p = 0; p < 3; p++) { + uint32_t pw = (p > 0 && is_yuv422_format(fmt)) ? w / 2 : w; + size_t stride = pw + pad_px; + uint16_t* plane = malloc(stride * h * sizeof(uint16_t)); + for (size_t i = 0; i < stride * h; i++) + plane[i] = value >= 0 ? (uint16_t)value : (uint16_t)(rnd() & mask); + f->addr[p] = plane; + f->linesize[p] = stride * sizeof(uint16_t); + } +} + +static void frame_free(struct st_frame* f) { + for (int p = 0; p < ST_MAX_PLANES; p++) { + free(f->addr[p]); + f->addr[p] = NULL; + } +} + +/* Expected BGRA of source pixel (x, y), written from the format definitions. */ +static void ref_bgra(const struct st_frame* f, uint32_t x, uint32_t y, uint8_t out[4]) { + int sh = is_12bit(f->fmt) ? 4 : 2; + const uint16_t* p0 = (const uint16_t*)f->addr[0] + y * (f->linesize[0] / 2); + const uint16_t* p1 = (const uint16_t*)f->addr[1] + y * (f->linesize[1] / 2); + const uint16_t* p2 = (const uint16_t*)f->addr[2] + y * (f->linesize[2] / 2); + out[3] = 255; + if (f->fmt == ST_FRAME_FMT_GBRPLANAR10LE || f->fmt == ST_FRAME_FMT_GBRPLANAR12LE) { + out[0] = (p1[x] >> sh) & 0xFF; /* plane order is G, B, R */ + out[1] = (p0[x] >> sh) & 0xFF; + out[2] = (p2[x] >> sh) & 0xFF; + return; + } + uint32_t cx = is_yuv422_format(f->fmt) ? x / 2 : x; + yuv_to_rgb((p0[x] >> sh) & 0xFF, (p1[cx] >> sh) & 0xFF, (p2[cx] >> sh) & 0xFF, + &out[2], &out[1], &out[0]); +} + +static const enum st_frame_fmt k_planar_fmts[] = { + ST_FRAME_FMT_YUV422PLANAR10LE, ST_FRAME_FMT_YUV422PLANAR12LE, + ST_FRAME_FMT_YUV444PLANAR10LE, ST_FRAME_FMT_YUV444PLANAR12LE, + ST_FRAME_FMT_GBRPLANAR10LE, ST_FRAME_FMT_GBRPLANAR12LE, +}; +#define N_PLANAR_FMTS (sizeof(k_planar_fmts) / sizeof(k_planar_fmts[0])) + +/* ------------------------------------------------------------------ tests */ + +static void test_yuv_to_rgb_reference_colours(void) { + uint8_t r, g, b; + yuv_to_rgb(16, 128, 128, &r, &g, &b); /* studio black */ + CHECK(r == 0 && g == 0 && b == 0); + yuv_to_rgb(235, 128, 128, &r, &g, &b); /* studio white */ + CHECK(r == 255 && g == 255 && b == 255); + yuv_to_rgb(81, 90, 240, &r, &g, &b); /* BT.601 red */ + CHECK(r == 255 && g == 0 && b == 0); + yuv_to_rgb(126, 128, 128, &r, &g, &b); /* mid grey: neutral chroma keeps r == g == b */ + CHECK(r == g && g == b && r > 100 && r < 150); + yuv_to_rgb(0, 128, 128, &r, &g, &b); /* below black clamps to 0 */ + CHECK(r == 0 && g == 0 && b == 0); + yuv_to_rgb(255, 255, 255, &r, &g, &b); /* above white clamps to 255 */ + CHECK(r == 255 && b == 255); +} + +static void test_format_helpers(void) { + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV422PLANAR10LE), 2); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV444PLANAR10LE), 2); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_GBRPLANAR10LE), 2); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV422PLANAR12LE), 4); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV444PLANAR12LE), 4); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_GBRPLANAR12LE), 4); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV420PLANAR8), 0); + CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_BGRA), 0); + CHECK(is_yuv422_format(ST_FRAME_FMT_YUV422PLANAR10LE)); + CHECK(is_yuv422_format(ST_FRAME_FMT_YUV422PLANAR12LE)); + CHECK(!is_yuv422_format(ST_FRAME_FMT_YUV444PLANAR10LE)); + CHECK(!is_yuv422_format(ST_FRAME_FMT_GBRPLANAR12LE)); +} + +static void test_convert_rejects_bad_input(void) { + struct st_frame f; + uint8_t buf[16 * 4 * 4]; + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 4, 4, 0, 0); + CHECK_EQ_INT(convert_frame_to_rgb(NULL, buf, 4, 4), -1); + CHECK_EQ_INT(convert_frame_to_rgb(&f, NULL, 4, 4), -1); + CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 4, 2), -1); /* size mismatch */ + CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 2, 4), -1); + void* y = f.addr[0]; + f.addr[0] = NULL; + CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 4, 4), -1); + f.addr[0] = y; + frame_free(&f); +} + +static void test_convert_uniform_white_all_formats(void) { + for (size_t i = 0; i < N_PLANAR_FMTS; i++) { + struct st_frame f; + enum st_frame_fmt fmt = k_planar_fmts[i]; + bool gbr = fmt == ST_FRAME_FMT_GBRPLANAR10LE || fmt == ST_FRAME_FMT_GBRPLANAR12LE; + int sh = is_12bit(fmt) ? 4 : 2; + frame_alloc(&f, fmt, 8, 2, 0, (gbr ? 255 : 235) << sh); + if (!gbr) { /* neutral chroma */ + for (int p = 1; p < 3; p++) + for (size_t k = 0; k < f.linesize[p] / 2 * f.height; k++) + ((uint16_t*)f.addr[p])[k] = 128 << sh; + } + uint8_t buf[8 * 2 * 4]; + memset(buf, 0, sizeof(buf)); + CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 8, 2), 0); + for (size_t k = 0; k < sizeof(buf); k++) CHECK_EQ_INT(buf[k], 255); + frame_free(&f); + } +} + +/* Random content with padded lines: every pixel must match the reference. */ +static void test_convert_matches_reference_all_formats(void) { + const uint32_t w = 38, h = 7, pad = 5; + for (size_t i = 0; i < N_PLANAR_FMTS; i++) { + struct st_frame f; + frame_alloc(&f, k_planar_fmts[i], w, h, pad, -1); + uint8_t* buf = malloc(w * h * 4); + CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, w, h), 0); + int bad = 0; + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t exp[4]; + ref_bgra(&f, x, y, exp); + if (memcmp(exp, buf + (y * w + x) * 4, 4) != 0) bad++; + } + if (bad) fprintf(stderr, " fmt %d: %d pixel(s) differ\n", k_planar_fmts[i], bad); + CHECK_EQ_INT(bad, 0); + free(buf); + frame_free(&f); + } +} + +static void test_convert_bit_depth_uses_top_bits(void) { + /* 12-bit 0xABC keeps 0xAB, 10-bit 0x2F3 keeps 0xBC (drops the 2 low bits) */ + struct st_frame f; + uint8_t px[8]; + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR12LE, 2, 1, 0, 0xABC); + CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 2, 1), 0); + CHECK(px[0] == 0xAB && px[1] == 0xAB && px[2] == 0xAB && px[3] == 255); + frame_free(&f); + + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 2, 1, 0, 0x2F3); + CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 2, 1), 0); + CHECK(px[0] == 0xBC && px[1] == 0xBC && px[2] == 0xBC); + frame_free(&f); +} + +static void test_convert_gbr_plane_order(void) { + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 1, 1, 0, 0); + ((uint16_t*)f.addr[0])[0] = 20 << 2; /* G */ + ((uint16_t*)f.addr[1])[0] = 30 << 2; /* B */ + ((uint16_t*)f.addr[2])[0] = 10 << 2; /* R */ + uint8_t px[4]; + CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 1, 1), 0); + CHECK(px[0] == 30 && px[1] == 20 && px[2] == 10 && px[3] == 255); /* B, G, R, A */ + frame_free(&f); +} + +static void test_convert_yuv422_shares_chroma(void) { + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 4, 1, 0, 126 << 2); + ((uint16_t*)f.addr[1])[0] = 128 << 2; /* pixels 0,1: neutral */ + ((uint16_t*)f.addr[2])[0] = 128 << 2; + ((uint16_t*)f.addr[1])[1] = 90 << 2; /* pixels 2,3: reddish */ + ((uint16_t*)f.addr[2])[1] = 240 << 2; + uint8_t px[16]; + CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 4, 1), 0); + CHECK(memcmp(px, px + 4, 4) == 0); + CHECK(memcmp(px + 8, px + 12, 4) == 0); + CHECK(memcmp(px, px + 8, 4) != 0); + CHECK(px[0] == px[1] && px[1] == px[2]); /* grey */ + CHECK(px[10] > px[8]); /* red > blue */ + frame_free(&f); +} + +static void test_convert_bgra_passthrough(void) { + uint8_t src[3 * 2 * 4], dst[3 * 2 * 4]; + for (size_t i = 0; i < sizeof(src); i++) src[i] = (uint8_t)(i * 7); + struct st_frame f = {0}; + f.fmt = ST_FRAME_FMT_BGRA; + f.width = 3; + f.height = 2; + f.addr[0] = src; + CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, 3, 2), 0); + CHECK(memcmp(src, dst, sizeof(src)) == 0); +} + +static void test_convert_i420_custom8(void) { + const uint32_t w = 6, h = 4; + uint8_t src[6 * 4 + 2 * 3 * 2]; + for (size_t i = 0; i < sizeof(src); i++) src[i] = (uint8_t)(rnd() & 0xFF); + struct st_frame f = {0}; + f.fmt = ST_FRAME_FMT_YUV420CUSTOM8; + f.width = w; + f.height = h; + f.addr[0] = src; + uint8_t dst[6 * 4 * 4]; + CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, w, h), 0); + int bad = 0; + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t r, g, b; + uint8_t U = src[w * h + (y / 2) * (w / 2) + x / 2]; + uint8_t V = src[w * h + (w / 2) * (h / 2) + (y / 2) * (w / 2) + x / 2]; + yuv_to_rgb(src[y * w + x], U, V, &r, &g, &b); + const uint8_t* px = dst + (y * w + x) * 4; + if (px[0] != b || px[1] != g || px[2] != r || px[3] != 255) bad++; + } + CHECK_EQ_INT(bad, 0); +} + +static void test_convert_unsupported_fills_pattern(void) { + uint8_t src[4 * 3 * 2]; + struct st_frame f = {0}; + f.fmt = ST_FRAME_FMT_UYVY; + f.width = 4; + f.height = 3; + f.addr[0] = src; + uint8_t dst[4 * 3 * 4]; + CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, 4, 3), 0); + const uint8_t* px = dst + (2 * 4 + 3) * 4; /* x = 3, y = 2 */ + CHECK(px[0] == 5 && px[1] == 3 && px[2] == 2 && px[3] == 255); +} + +static void test_convert_yuv420_planar8(void) { + const uint32_t w = 6, h = 4; + uint8_t py[6 * 4], pu[3 * 2], pv[3 * 2]; + for (size_t i = 0; i < sizeof(py); i++) py[i] = (uint8_t)rnd(); + for (size_t i = 0; i < sizeof(pu); i++) pu[i] = (uint8_t)rnd(), pv[i] = (uint8_t)rnd(); + struct st_frame f = {0}; + f.fmt = ST_FRAME_FMT_YUV420PLANAR8; + f.width = w; + f.height = h; + f.addr[0] = py; + f.addr[1] = pu; + f.addr[2] = pv; + uint8_t dst[6 * 4 * 4]; + CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, w, h), 0); + int bad = 0; + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t r, g, b; + uint32_t c = (y / 2) * (w / 2) + x / 2; + yuv_to_rgb(py[y * w + x], pu[c], pv[c], &r, &g, &b); + const uint8_t* px = dst + (y * w + x) * 4; + if (px[0] != b || px[1] != g || px[2] != r || px[3] != 255) bad++; + } + CHECK_EQ_INT(bad, 0); +} + +static void test_scaled_rejects_bad_input(void) { + struct st_frame f; + uint8_t buf[64]; + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 4, 2, 0, 0); + CHECK_EQ_INT(convert_frame_to_rgb_scaled(NULL, buf, 2, 2), -1); + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, NULL, 2, 2), -1); + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 0, 2), -1); + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 0), -1); + void* v = f.addr[2]; + f.addr[2] = NULL; + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 2), -1); + f.addr[2] = v; + f.fmt = ST_FRAME_FMT_BGRA; /* only 16-bit planar formats are scaled */ + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 2), -1); + f.fmt = ST_FRAME_FMT_YUV422PLANAR10LE; + f.width = 0; + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 2), -1); + f.width = 4; + frame_free(&f); +} + +static void test_scaled_identity_equals_full_conversion(void) { + const uint32_t w = 24, h = 6; + for (size_t i = 0; i < N_PLANAR_FMTS; i++) { + struct st_frame f; + frame_alloc(&f, k_planar_fmts[i], w, h, 3, -1); + uint8_t a[24 * 6 * 4], b[24 * 6 * 4]; + CHECK_EQ_INT(convert_frame_to_rgb(&f, a, w, h), 0); + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, b, w, h), 0); + CHECK(memcmp(a, b, sizeof(a)) == 0); + frame_free(&f); + } +} + +/* Nearest neighbour: dst(x, y) = src(x * sw / dw, y * sh / dh). */ +static void check_scaled(uint32_t sw, uint32_t sh, uint32_t dw, uint32_t dh) { + for (size_t i = 0; i < N_PLANAR_FMTS; i++) { + struct st_frame f; + frame_alloc(&f, k_planar_fmts[i], sw, sh, 2, -1); + uint8_t* buf = malloc((size_t)dw * dh * 4); + CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, dw, dh), 0); + int bad = 0; + for (uint32_t y = 0; y < dh; y++) + for (uint32_t x = 0; x < dw; x++) { + uint8_t exp[4]; + ref_bgra(&f, (uint32_t)((uint64_t)x * sw / dw), (uint32_t)((uint64_t)y * sh / dh), exp); + if (memcmp(exp, buf + ((size_t)y * dw + x) * 4, 4) != 0) bad++; + } + if (bad) fprintf(stderr, " fmt %d %ux%u -> %ux%u: %d pixel(s) differ\n", + k_planar_fmts[i], sw, sh, dw, dh, bad); + CHECK_EQ_INT(bad, 0); + free(buf); + frame_free(&f); + } +} + +static void test_scaled_up_and_down(void) { + check_scaled(16, 8, 32, 16); /* 2x up */ + check_scaled(16, 8, 8, 4); /* 2x down */ + check_scaled(16, 8, 5, 3); /* odd down */ + check_scaled(12, 6, 30, 23); /* non-integer up */ + check_scaled(20, 10, 20, 40); /* stretch one axis */ +} + +int main(void) { + logger_set_level(LOG_LEVEL_ERROR + 1); /* keep expected warnings out of the output */ + scratch_enter("converter"); + + RUN_TEST(test_yuv_to_rgb_reference_colours); + RUN_TEST(test_format_helpers); + RUN_TEST(test_convert_rejects_bad_input); + RUN_TEST(test_convert_uniform_white_all_formats); + RUN_TEST(test_convert_matches_reference_all_formats); + RUN_TEST(test_convert_bit_depth_uses_top_bits); + RUN_TEST(test_convert_gbr_plane_order); + RUN_TEST(test_convert_yuv422_shares_chroma); + RUN_TEST(test_convert_bgra_passthrough); + RUN_TEST(test_convert_i420_custom8); + RUN_TEST(test_convert_unsupported_fills_pattern); + RUN_TEST(test_convert_yuv420_planar8); + RUN_TEST(test_scaled_rejects_bad_input); + RUN_TEST(test_scaled_identity_equals_full_conversion); + RUN_TEST(test_scaled_up_and_down); + + scratch_leave(); + return test_summary(); +} diff --git a/dvledrx/tests/test_logger.c b/dvledrx/tests/test_logger.c new file mode 100644 index 0000000..d913590 --- /dev/null +++ b/dvledrx/tests/test_logger.c @@ -0,0 +1,343 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * Unit tests for logger: level parsing/filtering, safe log paths, file output + * and size-based rotation into gzip archives. + */ + +#include "test_common.h" + +#include +#include + +#include "logger.h" + +/* Read a whole (plain or gzip) file into buf; returns bytes read or -1. */ +static long read_gz(const char* path, char* buf, size_t len) { + gzFile gz = gzopen(path, "rb"); + if (!gz) return -1; + int n = gzread(gz, buf, (unsigned)len - 1); + gzclose(gz); + if (n < 0) return -1; + buf[n] = '\0'; + return n; +} + +static void test_parse_level(void) { + enum log_level l = LOG_LEVEL_ERROR; + CHECK(logger_parse_level("debug", &l) && l == LOG_LEVEL_DEBUG); + CHECK(logger_parse_level("INFO", &l) && l == LOG_LEVEL_INFO); + CHECK(logger_parse_level("Warn", &l) && l == LOG_LEVEL_WARN); + CHECK(logger_parse_level("warning", &l) && l == LOG_LEVEL_WARN); + CHECK(logger_parse_level("error", &l) && l == LOG_LEVEL_ERROR); + l = LOG_LEVEL_INFO; + CHECK(!logger_parse_level("verbose", &l)); + CHECK(!logger_parse_level("", &l)); + CHECK(!logger_parse_level("debugdebugdebugdebug", &l)); /* longer than the buffer */ + CHECK(l == LOG_LEVEL_INFO); /* untouched on failure */ + CHECK(!logger_parse_level(NULL, &l)); + CHECK(!logger_parse_level("info", NULL)); +} + +static void test_set_get_level_and_env(void) { + logger_set_level(LOG_LEVEL_WARN); + CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_WARN); + + setenv("RXAPP_LOG_LEVEL", "debug", 1); + logger_init_from_env(); + CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_DEBUG); + + setenv("RXAPP_LOG_LEVEL", "bogus", 1); + logger_init_from_env(); + CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_DEBUG); /* invalid value ignored */ + + unsetenv("RXAPP_LOG_LEVEL"); + logger_set_level(LOG_LEVEL_INFO); + logger_init_from_env(); + CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_INFO); +} + +static void test_path_is_safe(void) { + char why[512]; + CHECK(logger_path_is_safe("app.log", why, sizeof(why))); + CHECK(logger_path_is_safe("./app.log", why, sizeof(why))); + + REQUIRE(mkdir("logs", 0750) == 0); + CHECK(logger_path_is_safe("logs/app.log", why, sizeof(why))); + + why[0] = '\0'; + CHECK(!logger_path_is_safe("", why, sizeof(why))); + CHECK(why[0] != '\0'); + CHECK(!logger_path_is_safe(NULL, why, sizeof(why))); + CHECK(!logger_path_is_safe("logs/", why, sizeof(why))); + CHECK(!logger_path_is_safe("nope/app.log", why, sizeof(why))); + CHECK(!logger_path_is_safe("/tmp/app.log", why, sizeof(why))); + CHECK(strstr(why, "world-writable") != NULL); + CHECK(!logger_path_is_safe("logs", why, sizeof(why))); /* existing directory */ +} + +static void test_path_is_safe_world_writable_parent(void) { + char why[512]; + REQUIRE(mkdir("open", 0750) == 0); + REQUIRE(chmod("open", 0777) == 0); + REQUIRE(mkdir("open/inner", 0750) == 0); + CHECK(!logger_path_is_safe("open/app.log", why, sizeof(why))); + CHECK(!logger_path_is_safe("open/inner/app.log", why, sizeof(why))); /* any ancestor */ + chmod("open", 0750); + CHECK(logger_path_is_safe("open/inner/app.log", why, sizeof(why))); +} + +static void test_path_is_safe_rejects_symlink(void) { + char why[512]; + REQUIRE(write_file("target.log", "x\n") == 0); + REQUIRE(symlink("target.log", "link.log") == 0); + CHECK(!logger_path_is_safe("link.log", why, sizeof(why))); + CHECK(strstr(why, "not a regular file") != NULL); + REQUIRE(symlink("/tmp", "tmplink") == 0); + CHECK(!logger_path_is_safe("tmplink/app.log", why, sizeof(why))); /* resolved dir checked */ +} + +static void test_file_output_and_level_filter(void) { + char buf[4096]; + logger_set_level(LOG_LEVEL_INFO); + logger_set_file("out.log", 0); + log_debug("t", "hidden %d", 1); + log_info("t", "visible %d", 2); + log_warn(NULL, "warned"); + log_error("t", "failed %s", "here"); + logger_close(); + + REQUIRE(read_gz("out.log", buf, sizeof(buf)) > 0); /* gzread also reads plain files */ + CHECK(strstr(buf, "hidden") == NULL); + CHECK(strstr(buf, "[INFO] [t] visible 2\n") != NULL); + CHECK(strstr(buf, "[WARN] [app] warned\n") != NULL); + CHECK(strstr(buf, "[ERROR] [t] failed here\n") != NULL); + CHECK(strchr(buf, '\033') == NULL); /* no colour codes in files */ + + struct stat st; + REQUIRE(stat("out.log", &st) == 0); + CHECK_EQ_INT(st.st_mode & 0777, 0640); +} + +static void test_file_appends(void) { + char buf[4096]; + REQUIRE(write_file("append.log", "previous run\n") == 0); + logger_set_file("append.log", 0); + log_info("t", "new run"); + logger_close(); + REQUIRE(read_gz("append.log", buf, sizeof(buf)) > 0); + CHECK(strncmp(buf, "previous run\n", 13) == 0); + CHECK(strstr(buf, "new run") != NULL); +} + +static void test_unsafe_file_refused(void) { + REQUIRE(write_file("real.log", "") == 0); + REQUIRE(symlink("real.log", "evil.log") == 0); + logger_set_file("evil.log", 0); + log_info("t", "must not reach the symlink target"); + logger_close(); + CHECK_EQ_INT(file_size("real.log"), 0); + + logger_set_file("no-such-dir/x.log", 0); + log_info("t", "console"); + logger_close(); + CHECK(!file_exists("no-such-dir")); +} + +static void test_rotation_creates_gzip_archive(void) { + char buf[8192]; + logger_set_file("rot.log", 1024); + /* ~63-byte lines: the file passes 1024 bytes after 17 lines, so exactly one rotation */ + for (int i = 0; i < 20; i++) log_info("t", "line %02d padding padding padding", i); + logger_close(); /* joins the archiver thread */ + + CHECK(!file_exists("rot.log.rotating")); + CHECK(!file_exists("rot.log.new.gz")); + CHECK(!file_exists("rot.log.2.gz")); + REQUIRE(file_exists("rot.log.1.gz")); + REQUIRE(read_gz("rot.log.1.gz", buf, sizeof(buf)) > 0); + CHECK(strstr(buf, "line 00") != NULL); + CHECK(strstr(buf, "line 15") != NULL); + CHECK(strstr(buf, "line 19") == NULL); + REQUIRE(read_gz("rot.log", buf, sizeof(buf)) > 0); + CHECK(strstr(buf, "--- log rotated") != NULL); + CHECK(strstr(buf, "line 00") == NULL); + CHECK(strstr(buf, "line 19") != NULL); +} + +static void test_rotation_keeps_max_archives(void) { + char name[64], buf[8192]; + for (int r = 0; r < LOGGER_MAX_ARCHIVES + 3; r++) { + logger_set_file("keep.log", 256); + for (int i = 0; i < 6; i++) log_info("t", "round %02d line %d ........................", r, i); + logger_close(); + } + for (int i = 1; i <= LOGGER_MAX_ARCHIVES; i++) { + snprintf(name, sizeof(name), "keep.log.%d.gz", i); + CHECK(file_exists(name)); + } + snprintf(name, sizeof(name), "keep.log.%d.gz", LOGGER_MAX_ARCHIVES + 1); + CHECK(!file_exists(name)); + CHECK(!file_exists("keep.log.rotating")); + + /* newest archive is .1.gz: it holds a later round than .10.gz */ + REQUIRE(read_gz("keep.log.1.gz", buf, sizeof(buf)) > 0); + char* p1 = strstr(buf, "round "); + REQUIRE(p1 != NULL); + int newest = atoi(p1 + 6); + snprintf(name, sizeof(name), "keep.log.%d.gz", LOGGER_MAX_ARCHIVES); + REQUIRE(read_gz(name, buf, sizeof(buf)) > 0); + char* p10 = strstr(buf, "round "); + REQUIRE(p10 != NULL); + CHECK(atoi(p10 + 6) < newest); +} + +static void test_debug_level_and_reopen(void) { + char buf[4096]; + logger_set_level(LOG_LEVEL_DEBUG); + logger_set_file("first.log", 0); + log_debug("t", "to first"); + logger_set_file("second.log", 0); /* switching files closes the first one */ + log_debug("t", "to second"); + logger_close(); + logger_set_level(LOG_LEVEL_INFO); + REQUIRE(read_gz("first.log", buf, sizeof(buf)) > 0); + CHECK(strstr(buf, "[DEBUG] [t] to first") != NULL); + CHECK(strstr(buf, "to second") == NULL); + REQUIRE(read_gz("second.log", buf, sizeof(buf)) > 0); + CHECK(strstr(buf, "[DEBUG] [t] to second") != NULL); +} + +static void test_path_in_root_dir_and_too_long(void) { + char why[512], path[600] = ""; + CHECK(logger_path_is_safe("/rx-unit-test-not-created.log", why, sizeof(why))); + + while (strlen(path) < 520) strcat(path, "./"); + strcat(path, "long.log"); + CHECK(logger_path_is_safe(path, why, sizeof(why))); + logger_set_file(path, 0); /* safe but longer than the logger keeps: console */ + log_info("t", "console"); + logger_close(); + CHECK(!file_exists("long.log")); +} + +static void test_unwritable_dir_uses_console(void) { + if (geteuid() == 0) { + fprintf(stderr, " (skipped as root)\n"); + return; + } + REQUIRE(mkdir("ro", 0550) == 0); + logger_set_file("ro/app.log", 0); + log_info("t", "console"); + logger_close(); + CHECK(!file_exists("ro/app.log")); + chmod("ro", 0750); +} + +/* A leftover that cannot be compressed is kept (never overwritten) and the live + * log keeps growing past the limit instead of rotating. */ +static void test_failed_compression_keeps_pending(void) { + char buf[256]; + if (geteuid() == 0) { + fprintf(stderr, " (skipped as root)\n"); + return; + } + REQUIRE(write_file("stuck.log.rotating", "precious\n") == 0); + REQUIRE(chmod("stuck.log.rotating", 0) == 0); + logger_set_file("stuck.log", 256); + for (int i = 0; i < 20; i++) log_info("t", "line %02d ..............................", i); + logger_close(); + CHECK(!file_exists("stuck.log.1.gz")); + CHECK(!file_exists("stuck.log.new.gz")); + CHECK(file_size("stuck.log") > 256 * 3); + REQUIRE(chmod("stuck.log.rotating", 0640) == 0); + REQUIRE(read_gz("stuck.log.rotating", buf, sizeof(buf)) > 0); + CHECK_EQ_STR(buf, "precious\n"); + + logger_set_file("stuck.log", 0); /* readable again: archived on the next start */ + logger_close(); + CHECK(!file_exists("stuck.log.rotating")); + REQUIRE(read_gz("stuck.log.1.gz", buf, sizeof(buf)) > 0); + CHECK_EQ_STR(buf, "precious\n"); +} + +/* Directory made read-only while logging: rotation (rename) and archiving (temp + * file) fail, the live log keeps working and nothing is lost. */ +static void test_readonly_dir_rotation_fails_safely(void) { + char buf[8192]; + if (geteuid() == 0) { + fprintf(stderr, " (skipped as root)\n"); + return; + } + REQUIRE(mkdir("rodir", 0750) == 0); + logger_set_file("rodir/app.log", 256); + REQUIRE(chmod("rodir", 0550) == 0); + for (int i = 0; i < 10; i++) log_info("t", "line %02d ..............................", i); + logger_close(); + REQUIRE(read_gz("rodir/app.log", buf, sizeof(buf)) > 0); + CHECK(strstr(buf, "--- log rotation failed") != NULL); + CHECK(strstr(buf, "line 00") != NULL && strstr(buf, "line 09") != NULL); + CHECK(!file_exists("rodir/app.log.rotating")); + + REQUIRE(chmod("rodir", 0750) == 0); + REQUIRE(write_file("rodir/app.log.rotating", "pending\n") == 0); + REQUIRE(chmod("rodir", 0550) == 0); + logger_set_file("rodir/app.log", 0); /* cannot create the temp archive */ + logger_close(); + CHECK(file_exists("rodir/app.log.rotating")); + CHECK(!file_exists("rodir/app.log.1.gz")); + chmod("rodir", 0750); +} + +static void test_path_owned_by_other_user(void) { + /* system directories owned by service accounts, when present */ + static const char* dirs[] = {"/var/cache/man", "/var/lib/colord", "/var/spool/cups", + "/var/lib/gdm3", "/var/log/cups"}; + char why[512], path[PATH_MAX]; + for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + struct stat st; + if (stat(dirs[i], &st) != 0 || !S_ISDIR(st.st_mode) || st.st_uid == 0 || + st.st_uid == geteuid() || (st.st_mode & S_IWOTH)) + continue; + snprintf(path, sizeof(path), "%s/app.log", dirs[i]); + CHECK(!logger_path_is_safe(path, why, sizeof(why))); + CHECK(strstr(why, "owned by another user") != NULL); + return; + } + fprintf(stderr, " (skipped: no directory owned by another user found)\n"); +} + +static void test_interrupted_rotation_recovered(void) { + char buf[1024]; + REQUIRE(write_file("crash.log.rotating", "left over from a crash\n") == 0); + logger_set_file("crash.log", 0); + logger_close(); + CHECK(!file_exists("crash.log.rotating")); + REQUIRE(read_gz("crash.log.1.gz", buf, sizeof(buf)) > 0); + CHECK(strstr(buf, "left over from a crash") != NULL); +} + +int main(void) { + scratch_enter("logger"); + + RUN_TEST(test_parse_level); + RUN_TEST(test_set_get_level_and_env); + RUN_TEST(test_path_is_safe); + RUN_TEST(test_path_is_safe_world_writable_parent); + RUN_TEST(test_path_is_safe_rejects_symlink); + RUN_TEST(test_file_output_and_level_filter); + RUN_TEST(test_file_appends); + RUN_TEST(test_unsafe_file_refused); + RUN_TEST(test_rotation_creates_gzip_archive); + RUN_TEST(test_rotation_keeps_max_archives); + RUN_TEST(test_interrupted_rotation_recovered); + RUN_TEST(test_debug_level_and_reopen); + RUN_TEST(test_path_in_root_dir_and_too_long); + RUN_TEST(test_unwritable_dir_uses_console); + RUN_TEST(test_failed_compression_keeps_pending); + RUN_TEST(test_readonly_dir_rotation_fails_safely); + RUN_TEST(test_path_owned_by_other_user); + + scratch_leave(); + return test_summary(); +} diff --git a/dvledrx/tests/test_session_manager.c b/dvledrx/tests/test_session_manager.c new file mode 100644 index 0000000..6b87f23 --- /dev/null +++ b/dvledrx/tests/test_session_manager.c @@ -0,0 +1,435 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * Unit tests for session_manager against the fake MTL (tests/fake_mtl.c) and a + * virtual X server (run under xvfb-run). Checks every frame taken from MTL is + * returned, sessions/windows are set up as configured and failures unwind. + */ + +#include "test_common.h" + +#include +#include +#include + +#include "config_reader.h" +#include "fake_mtl.h" +#include "logger.h" +#include "session_manager.h" + +static char g_display_env[64]; +static char g_output[32]; /* first XRandR output of the X server */ + +static void sleep_ms(int ms) { + struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +} + +static void no_display(void) { unsetenv("DISPLAY"); } +static void with_display(void) { setenv("DISPLAY", g_display_env, 1); } + +/* Parse json into app the way main() does (fmt yuv422p10le, transport from fmt). */ +static int make_app(struct rx_app_context* app, const char* json) { + memset(app, 0, sizeof(*app)); + if (write_file("cfg.json", json) != 0 || parse_rx_config("cfg.json", &app->json_config) != 0) + return -1; + app->width = (uint32_t)app->json_config.video.width; + app->height = (uint32_t)app->json_config.video.height; + app->fps = ST_FPS_P30; + app->fmt = ST_FRAME_FMT_YUV422PLANAR10LE; + app->st20p_sessions = app->json_config.num_rx_sessions; + return 0; +} + +/* Main-loop stand-in: pump X events for ms milliseconds. */ +static void pump(session_manager_t* m, int ms) { + for (int t = 0; t < ms; t += 10) { + if (m->composite) process_composite_x11_events(m->composite); + for (int i = 0; i < m->st20p_count && m->st20p_sessions; i++) { + process_x11_events(&m->st20p_sessions[i].display); + for (int k = 0; k < m->st20p_sessions[i].num_mirror_displays; k++) + process_x11_events(&m->st20p_sessions[i].mirror_display[k]); + } + sleep_ms(10); + } +} + +/* Every frame handed out by MTL must come back exactly once, nothing leaked. */ +static void check_frames_balanced(void) { + CHECK_EQ_INT(fake_mtl.frames_put, fake_mtl.frames_got); + CHECK_EQ_INT(fake_mtl.bad_puts, 0); + CHECK_EQ_INT(fake_mtl.leaked, 0); +} + +/* init + start, run for ms, then stop/cleanup as main() does. */ +static int run_app(struct rx_app_context* app, int ms, session_manager_t* seen) { + session_manager_t m; + if (session_manager_init(&m, app) != 0) return -1; + int rc = session_manager_start(&m, app); + if (rc == 0) pump(&m, ms); + if (seen) *seen = m; + app->exit = true; + session_manager_cleanup(&m); + CHECK(!session_manager_is_running(&m)); + return rc; +} + +#define IFACE "\"interfaces\": [ { \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.168.85.20\" } ]" + +/* ------------------------------------------------------------------ tests */ + +static void test_null_args(void) { + struct rx_app_context app; + session_manager_t m; + memset(&app, 0, sizeof(app)); + CHECK_EQ_INT(session_manager_init(NULL, &app), -1); + CHECK_EQ_INT(session_manager_init(&m, NULL), -1); + CHECK_EQ_INT(session_manager_start(NULL, &app), -1); + CHECK_EQ_INT(session_manager_start(&m, NULL), -1); + session_manager_stop(NULL); + session_manager_cleanup(NULL); + CHECK(!session_manager_is_running(NULL)); +} + +static void test_init_failure(void) { + struct rx_app_context app; + session_manager_t m; + REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ] }") == 0); + fake_mtl_reset(); + fake_mtl.fail_init = true; + CHECK_EQ_INT(session_manager_init(&m, &app), -1); + CHECK(m.mtl == NULL); + session_manager_cleanup(&m); + CHECK_EQ_INT(fake_mtl.uninit_calls, 0); +} + +static void test_init_params(void) { + struct rx_app_context app; + session_manager_t m; + REQUIRE(make_app(&app, + "{ \"tasklet_sleep\": true, \"interfaces\": [\n" + " { \"nic_index\": 0, \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.1.1.1\" },\n" + " { \"nic_index\": 1, \"name\": \"0000:03:00.1\", \"sip\": \"192.168.50.66\", \"dip\": \"239.1.1.2\" } ],\n" + " \"video\": { \"width\": 96, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"nic_index\": 0, \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n" + " { \"nic_index\": 0, \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } },\n" + " { \"nic_index\": 1, \"udp_port\": 20004, \"crop\": { \"x\": 64, \"w\": 32, \"h\": 16 } } ] }") == 0); + fake_mtl_reset(); + REQUIRE(session_manager_init(&m, &app) == 0); + const struct mtl_init_params* p = &fake_mtl.last_init; + CHECK_EQ_INT(p->num_ports, 2); + CHECK_EQ_STR(p->port[0], "0000:03:00.0"); + CHECK_EQ_STR(p->port[1], "0000:03:00.1"); + CHECK(p->sip_addr[1][0] == 192 && p->sip_addr[1][3] == 66); + CHECK_EQ_INT(p->rx_queues_cnt[0], 2 + 2); /* sessions on the port + 2 housekeeping */ + CHECK_EQ_INT(p->rx_queues_cnt[1], 1 + 2); + CHECK(p->flags & MTL_FLAG_TASKLET_SLEEP); + CHECK(p->flags & MTL_FLAG_RX_VIDEO_MIGRATE); + CHECK(!(p->flags & MTL_FLAG_RX_USE_CNI)); /* no VF */ + CHECK(app.mtl == m.mtl && m.mtl != NULL); + CHECK_EQ_INT(m.st20p_count, 3); + session_manager_cleanup(&m); + CHECK_EQ_INT(fake_mtl.uninit_calls, 1); +} + +/* Odd NIC names go through the PCI address check without touching sysfs paths. */ +static void test_unusual_nic_names(void) { + const char* names[] = {"03:00.0", "0000:03:00.8", "../../etc", "0000:3:00.0", "0000:03:00.0x", ""}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + char json[512]; + struct rx_app_context app; + session_manager_t m; + snprintf(json, sizeof(json), + "{ \"interfaces\": [ { \"name\": \"%s\", \"sip\": \"10.0.0.1\", \"dip\": \"239.1.1.1\" } ],\n" + " \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ] }", names[i]); + REQUIRE(make_app(&app, json) == 0); + fake_mtl_reset(); + CHECK_EQ_INT(session_manager_init(&m, &app), 0); + CHECK(!(fake_mtl.last_init.flags & MTL_FLAG_RX_USE_CNI)); + session_manager_cleanup(&m); + } +} + +static void test_single_session_without_display(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 32, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"udp_port\": 20010, \"payload_type\": 112,\n" + " \"crop\": { \"w\": 32, \"h\": 16 } } ] }") == 0); + no_display(); + fake_mtl_reset(); + fake_mtl.frames_per_session = 120; /* > 50 for the progress log */ + session_manager_t seen; + CHECK_EQ_INT(run_app(&app, 300, &seen), 0); + with_display(); + CHECK(seen.composite == NULL); + CHECK_EQ_INT(fake_mtl.frames_got, 120); + check_frames_balanced(); + CHECK_EQ_INT(fake_mtl.create_calls, 1); + CHECK_EQ_INT(fake_mtl.free_calls, 1); + CHECK_EQ_INT(fake_mtl.wake_calls, 1); + const struct st20p_rx_ops* ops = &fake_mtl.ops[0]; + CHECK_EQ_STR(ops->name, "st20p_rx_0"); + CHECK_EQ_INT(ops->port.udp_port[MTL_SESSION_PORT_P], 20010); + CHECK_EQ_INT(ops->port.payload_type, 112); + CHECK(ops->port.ip_addr[MTL_SESSION_PORT_P][0] == 239 && + ops->port.ip_addr[MTL_SESSION_PORT_P][3] == 20); + CHECK_EQ_STR(ops->port.port[MTL_SESSION_PORT_P], "0000:03:00.0"); + CHECK_EQ_INT(ops->width, 32); + CHECK_EQ_INT(ops->height, 16); + CHECK_EQ_INT(ops->transport_fmt, ST20_FMT_YUV_422_10BIT); + CHECK_EQ_INT(ops->output_fmt, ST_FRAME_FMT_YUV422PLANAR10LE); + CHECK(ops->flags & ST20P_RX_FLAG_BLOCK_GET); +} + +/* Frames handed to the display thread; runs past 1 s for the fps report. */ +static void test_single_session_window(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 48, \"height\": 32 },\n" + " \"rx_sessions\": [ { \"crop\": { \"w\": 48, \"h\": 32 } } ] }") == 0); + fake_mtl_reset(); + fake_mtl.empty_every = 7; /* frames without data are returned by the RX thread */ + session_manager_t seen; + CHECK_EQ_INT(run_app(&app, 1200, &seen), 0); + CHECK(fake_mtl.frames_got > 10); + check_frames_balanced(); +} + +/* No crop: the session uses the full video size. */ +static void test_session_size_from_video(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 40, \"height\": 20 },\n" + " \"rx_sessions\": [ { } ] }") == 0); + app.width = 40; + app.height = 20; + fake_mtl_reset(); + fake_mtl.frames_per_session = 5; + CHECK_EQ_INT(run_app(&app, 100, NULL), 0); + CHECK_EQ_INT(fake_mtl.ops[0].width, 40); + CHECK_EQ_INT(fake_mtl.ops[0].height, 20); + check_frames_balanced(); +} + +static void test_transport_format_mapping(void) { + const struct { enum st_frame_fmt f; enum st20_fmt t; } map[] = { + {ST_FRAME_FMT_YUV422PLANAR10LE, ST20_FMT_YUV_422_10BIT}, + {ST_FRAME_FMT_YUV422PLANAR12LE, ST20_FMT_YUV_422_12BIT}, + {ST_FRAME_FMT_YUV444PLANAR10LE, ST20_FMT_YUV_444_10BIT}, + {ST_FRAME_FMT_YUV444PLANAR12LE, ST20_FMT_YUV_444_12BIT}, + {ST_FRAME_FMT_GBRPLANAR10LE, ST20_FMT_RGB_10BIT}, + {ST_FRAME_FMT_GBRPLANAR12LE, ST20_FMT_RGB_12BIT}, + {ST_FRAME_FMT_YUV420PLANAR8, ST20_FMT_YUV_420_8BIT}, + {ST_FRAME_FMT_YUV420CUSTOM8, ST20_FMT_YUV_420_8BIT}, + {ST_FRAME_FMT_BGRA, ST20_FMT_YUV_422_10BIT}, /* unknown: default */ + }; + no_display(); + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); i++) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 8 } } ] }") == 0); + app.fmt = map[i].f; + fake_mtl_reset(); + fake_mtl.frames_per_session = 0; + CHECK_EQ_INT(run_app(&app, 0, NULL), 0); + CHECK_EQ_INT(fake_mtl.ops[0].transport_fmt, map[i].t); + CHECK_EQ_INT(fake_mtl.ops[0].output_fmt, map[i].f); + } + + /* An explicit transport format wins over the derived one. */ + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 8 } } ] }") == 0); + app.transport_fmt = ST20_FMT_RGB_8BIT; + fake_mtl_reset(); + fake_mtl.frames_per_session = 0; + CHECK_EQ_INT(run_app(&app, 0, NULL), 0); + CHECK_EQ_INT(fake_mtl.ops[0].transport_fmt, ST20_FMT_RGB_8BIT); + with_display(); +} + +/* Two sessions without display_mode: one composite window sized to the crops. + * Frames keep coming until stop, so a thread may be waiting at the barrier then. */ +static void test_composite_crops(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n" + " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0); + fake_mtl_reset(); + session_manager_t seen; + CHECK_EQ_INT(run_app(&app, 400, &seen), 0); + CHECK(seen.composite != NULL); + CHECK(fake_mtl.frames_got > 2); + check_frames_balanced(); +} + +/* Composite: session 1 fails to create while session 0 already waits at the barrier for it. */ +static void test_composite_create_failure_unwinds(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n" + " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0); + fake_mtl_reset(); + fake_mtl.fail_create_at = 1; + session_manager_t m; + REQUIRE(session_manager_init(&m, &app) == 0); + CHECK_EQ_INT(session_manager_start(&m, &app), -1); + sleep_ms(100); /* let session 0 reach the barrier */ + app.exit = true; + session_manager_cleanup(&m); + CHECK_EQ_INT(fake_mtl.free_calls, 1); + check_frames_balanced(); +} + +/* Composite window cannot open: sessions still receive. */ +static void test_composite_without_display(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n" + " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0); + no_display(); + fake_mtl_reset(); + fake_mtl.frames_per_session = 10; + session_manager_t seen; + CHECK_EQ_INT(run_app(&app, 200, &seen), 0); + with_display(); + CHECK(seen.composite == NULL); + CHECK_EQ_INT(fake_mtl.frames_got, 20); + check_frames_balanced(); +} + +/* per_output: fixed windows, a same-size mirror (copied) and a different-size one (converted). */ +static void test_per_output_windows_and_mirrors(void) { + char json[2048]; + snprintf(json, sizeof(json), + "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 32 },\n" + " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 32 } },\n" + " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 32 } } ],\n" + " \"displays\": [\n" + " { \"output\": \"%s\", \"sessions\": [\n" + " { \"session\": 0, \"x\": 0, \"y\": 0, \"scale_w\": 16, \"scale_h\": 16 },\n" + " { \"session\": 1, \"x\": 16, \"y\": 0 },\n" + " { \"session\": 0, \"x\": 0, \"y\": 40, \"scale_w\": 16, \"scale_h\": 16 },\n" + " { \"session\": 1, \"x\": 40, \"y\": 40, \"scale_w\": 8, \"scale_h\": 8 } ] },\n" + " { \"output\": \"NO-SUCH-OUTPUT\", \"sessions\": [ { \"session\": 1 } ] } ] }", + g_output); + struct rx_app_context app; + REQUIRE(make_app(&app, json) == 0); + fake_mtl_reset(); + session_manager_t seen; + CHECK_EQ_INT(run_app(&app, 1100, &seen), 0); + CHECK(seen.composite == NULL); + CHECK(fake_mtl.frames_got > 4); + check_frames_balanced(); +} + +/* per_output by index without a window/scale: fullscreen on the session's monitor. */ +static void test_per_output_fullscreen_by_index(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"display_mode\": \"per_output\", \"video\": { \"width\": 32, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ] }") == 0); + fake_mtl_reset(); + fake_mtl.frames_per_session = 8; + CHECK_EQ_INT(run_app(&app, 300, NULL), 0); + CHECK_EQ_INT(fake_mtl.frames_got, 8); + check_frames_balanced(); +} + +/* Output not connected: no window, frames are still received and returned. */ +static void test_per_output_missing_output(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 32, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ],\n" + " \"displays\": [ { \"output\": \"NO-SUCH-OUTPUT\", \"sessions\": [ { \"session\": 0 } ] } ] }") == 0); + fake_mtl_reset(); + fake_mtl.frames_per_session = 6; + session_manager_t seen; + CHECK_EQ_INT(run_app(&app, 200, &seen), 0); + CHECK_EQ_INT(fake_mtl.frames_got, 6); + check_frames_balanced(); +} + +/* Second session fails to create: start fails, the first session is still stopped and freed. */ +static void test_create_failure_unwinds(void) { + struct rx_app_context app; + REQUIRE(make_app(&app, "{ " IFACE ", \"display_mode\": \"per_output\", \"video\": { \"width\": 64, \"height\": 16 },\n" + " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n" + " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0); + no_display(); + fake_mtl_reset(); + fake_mtl.fail_create_at = 1; + CHECK_EQ_INT(run_app(&app, 0, NULL), -1); + with_display(); + CHECK_EQ_INT(fake_mtl.create_calls, 2); + CHECK_EQ_INT(fake_mtl.free_calls, 1); + CHECK_EQ_INT(fake_mtl.uninit_calls, 1); + check_frames_balanced(); +} + +static void test_stop_twice(void) { + struct rx_app_context app; + session_manager_t m; + REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 8 } } ] }") == 0); + no_display(); + fake_mtl_reset(); + REQUIRE(session_manager_init(&m, &app) == 0); + REQUIRE(session_manager_start(&m, &app) == 0); + CHECK(session_manager_is_running(&m)); + app.exit = true; + session_manager_stop(&m); + session_manager_stop(&m); + CHECK_EQ_INT(fake_mtl.free_calls, 1); + session_manager_cleanup(&m); + session_manager_cleanup(&m); + CHECK_EQ_INT(fake_mtl.uninit_calls, 1); + with_display(); + check_frames_balanced(); +} + +/* Name of the X server's first output (Xvfb calls it "screen"). */ +static int find_first_output(void) { + Display* dpy = XOpenDisplay(NULL); + if (!dpy) return -1; + int n = 0; + XRRMonitorInfo* mons = XRRGetMonitors(dpy, DefaultRootWindow(dpy), True, &n); + XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, DefaultRootWindow(dpy)); + if (n > 0 && mons[0].noutput > 0 && res) { + XRROutputInfo* info = XRRGetOutputInfo(dpy, res, mons[0].outputs[0]); + if (info) { + snprintf(g_output, sizeof(g_output), "%s", info->name); + XRRFreeOutputInfo(info); + } + } + if (res) XRRFreeScreenResources(res); + if (mons) XRRFreeMonitors(mons); + XCloseDisplay(dpy); + return g_output[0] ? 0 : -1; +} + +int main(void) { + XInitThreads(); + const char* d = getenv("DISPLAY"); + if (!d || find_first_output() != 0) { + fprintf(stderr, "needs an X server (run under xvfb-run)\n"); + return 77; /* meson: skipped */ + } + snprintf(g_display_env, sizeof(g_display_env), "%s", d); + logger_set_level(LOG_LEVEL_ERROR + 1); + scratch_enter("session"); + + RUN_TEST(test_null_args); + RUN_TEST(test_init_failure); + RUN_TEST(test_init_params); + RUN_TEST(test_unusual_nic_names); + RUN_TEST(test_single_session_without_display); + RUN_TEST(test_single_session_window); + RUN_TEST(test_session_size_from_video); + RUN_TEST(test_transport_format_mapping); + RUN_TEST(test_composite_crops); + RUN_TEST(test_composite_create_failure_unwinds); + RUN_TEST(test_composite_without_display); + RUN_TEST(test_per_output_windows_and_mirrors); + RUN_TEST(test_per_output_fullscreen_by_index); + RUN_TEST(test_per_output_missing_output); + RUN_TEST(test_create_failure_unwinds); + RUN_TEST(test_stop_twice); + + scratch_leave(); + return test_summary(); +} diff --git a/dvledrx/tests/test_x11.c b/dvledrx/tests/test_x11.c new file mode 100644 index 0000000..91b61e1 --- /dev/null +++ b/dvledrx/tests/test_x11.c @@ -0,0 +1,480 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright(c) 2026 Intel Corporation + * + * Unit tests for x11_handler on a virtual X server (run under xvfb-run): + * windows on outputs, frame conversion into the window image, mirrors, + * window events (expose, resize, close, Escape) and the composite window. + */ + +#include "test_common.h" + +#include +#include +#include +#include +#include +#include + +#include "config_reader.h" +#include "logger.h" +#include "st_pipeline_api.h" +#include "x11_handler.h" + +static Display* g_dpy; /* the test's own connection, used to send events */ +static char g_display_env[64]; +static char g_output[32]; + +static void sleep_ms(int ms) { + struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +} + +static Window win_of(const struct x11_display_ctx* d) { return (Window)(uintptr_t)d->window; } + +/* Let the server deliver what we sent, then run the handler's event pump. */ +static void pump(struct x11_display_ctx* d) { + XSync(g_dpy, False); + for (int i = 0; i < 5; i++) { + sleep_ms(10); + process_x11_events(d); + } +} + +static void send_event(Window w, long mask, XEvent* ev) { + XSendEvent(g_dpy, w, False, mask, ev); + XSync(g_dpy, False); +} + +static void send_key(Window w, bool press) { + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xkey.type = press ? KeyPress : KeyRelease; + ev.xkey.display = g_dpy; + ev.xkey.window = w; + ev.xkey.root = DefaultRootWindow(g_dpy); + ev.xkey.keycode = XKeysymToKeycode(g_dpy, XK_Escape); + ev.xkey.same_screen = True; + send_event(w, press ? KeyPressMask : KeyReleaseMask, &ev); +} + +static void send_expose(Window w) { + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xexpose.type = Expose; + ev.xexpose.window = w; + ev.xexpose.width = 1; + ev.xexpose.height = 1; + send_event(w, ExposureMask, &ev); +} + +static void send_close(Window w) { + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xclient.type = ClientMessage; + ev.xclient.window = w; + ev.xclient.message_type = XInternAtom(g_dpy, "WM_PROTOCOLS", False); + ev.xclient.format = 32; + ev.xclient.data.l[0] = (long)XInternAtom(g_dpy, "WM_DELETE_WINDOW", False); + send_event(w, NoEventMask, &ev); +} + +static void send_reparent_to_root(Window w) { + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xreparent.type = ReparentNotify; + ev.xreparent.event = w; + ev.xreparent.window = w; + ev.xreparent.parent = DefaultRootWindow(g_dpy); + send_event(w, StructureNotifyMask, &ev); +} + +/* 16-bit planar test frame, every sample = value (chroma neutral for YUV). */ +static void frame_alloc(struct st_frame* f, enum st_frame_fmt fmt, uint32_t w, uint32_t h, + uint16_t value) { + memset(f, 0, sizeof(*f)); + f->fmt = fmt; + f->width = w; + f->height = h; + bool sub = fmt == ST_FRAME_FMT_YUV422PLANAR10LE || fmt == ST_FRAME_FMT_YUV422PLANAR12LE; + bool gbr = fmt == ST_FRAME_FMT_GBRPLANAR10LE || fmt == ST_FRAME_FMT_GBRPLANAR12LE; + bool b12 = fmt == ST_FRAME_FMT_YUV422PLANAR12LE || fmt == ST_FRAME_FMT_YUV444PLANAR12LE || + fmt == ST_FRAME_FMT_GBRPLANAR12LE; + uint16_t neutral = (uint16_t)(128 << (b12 ? 4 : 2)); + for (int p = 0; p < 3; p++) { + uint32_t pw = (p > 0 && sub) ? w / 2 : w; + uint16_t* plane = malloc((size_t)pw * h * 2); + for (size_t i = 0; i < (size_t)pw * h; i++) plane[i] = (p > 0 && !gbr) ? neutral : value; + f->addr[p] = plane; + f->linesize[p] = (size_t)pw * 2; + f->data_size += (size_t)pw * h * 2; + } +} + +static void frame_free(struct st_frame* f) { + for (int p = 0; p < ST_MAX_PLANES; p++) free(f->addr[p]); + memset(f, 0, sizeof(*f)); +} + +static const uint8_t* px(const struct x11_display_ctx* d, uint32_t x, uint32_t y) { + return (const uint8_t*)d->image_data + ((size_t)y * d->width + x) * 4; +} + +/* ------------------------------------------------------------------ tests */ + +static void test_init_without_x_server(void) { + struct x11_display_ctx d; + unsetenv("DISPLAY"); + CHECK_EQ_INT(init_x11_display(&d, 16, 16, 0, NULL, false, NULL), -1); + struct composite_x11_ctx c; + CHECK_EQ_INT(init_composite_x11_display(&c, 16, 16, 1), -1); + setenv("DISPLAY", ":250", 1); /* nothing listens there */ + CHECK_EQ_INT(init_x11_display(&d, 16, 16, 0, NULL, false, NULL), -1); + CHECK_EQ_INT(init_composite_x11_display(&c, 16, 16, 1), -1); + setenv("DISPLAY", g_display_env, 1); + CHECK_EQ_INT(init_composite_x11_display(NULL, 16, 16, 1), -1); + CHECK_EQ_INT(init_composite_x11_display(&c, 16, 16, 0), -1); +} + +static void test_null_and_uninitialised_are_ignored(void) { + struct x11_display_ctx d; + memset(&d, 0, sizeof(d)); + cleanup_x11_display(NULL); + cleanup_x11_display(&d); + display_frame_x11(NULL, NULL); + display_frame_x11(&d, NULL); + process_x11_events(NULL); + process_x11_events(&d); + CHECK(!display_copy_x11(NULL, &d)); + CHECK(!display_copy_x11(&d, &d)); + struct composite_x11_ctx c; + memset(&c, 0, sizeof(c)); + cleanup_composite_x11_display(NULL); + cleanup_composite_x11_display(&c); + process_composite_x11_events(NULL); + process_composite_x11_events(&c); + composite_write_strip(&c, NULL, 0); + composite_write_crop(&c, NULL, NULL); +} + +/* Ordinary window: frame of the same size converts straight into the image. */ +static void test_normal_window_direct_frame(void) { + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 8, 4, 0, NULL, false, NULL) == 0); + CHECK(d.initialized); + CHECK_EQ_STR(d.output_name, "(window)"); + CHECK_EQ_INT(d.wall_kind, X11_WALL_NONE); + CHECK_EQ_INT(d.width, 8); + CHECK_EQ_INT(d.height, 4); + + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 8, 4, 235 << 2); /* white */ + for (int i = 0; i < 6; i++) display_frame_x11(&d, &f); /* > 5: past the diagnostics */ + const uint8_t* p = px(&d, 7, 3); + CHECK(p[0] == 255 && p[1] == 255 && p[2] == 255); + frame_free(&f); + + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR12LE, 8, 4, 16 << 4); /* black, 12-bit */ + display_frame_x11(&d, &f); + CHECK(px(&d, 0, 0)[0] == 0 && px(&d, 0, 0)[1] == 0 && px(&d, 0, 0)[2] == 0); + frame_free(&f); + + frame_alloc(&f, ST_FRAME_FMT_YUV444PLANAR12LE, 8, 4, 235 << 4); /* white, 12-bit */ + display_frame_x11(&d, &f); + CHECK(px(&d, 3, 2)[0] == 255 && px(&d, 3, 2)[1] == 255 && px(&d, 3, 2)[2] == 255); + frame_free(&f); + + send_expose(win_of(&d)); + pump(&d); + cleanup_x11_display(&d); + CHECK(!d.initialized); +} + +/* Frame size != window size: scaled conversion, BGRA scaler fallback, placeholder. */ +static void test_frame_scaling_and_fallbacks(void) { + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 8, 8, 1, NULL, false, NULL) == 0); + + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 4, 4, 0); + ((uint16_t*)f.addr[2])[0] = 200 << 2; /* R of source pixel (0,0) */ + display_frame_x11(&d, &f); /* 4x4 -> 8x8 nearest */ + CHECK(px(&d, 1, 1)[2] == 200 && px(&d, 0, 0)[2] == 200 && px(&d, 2, 0)[2] == 0); + frame_free(&f); + + uint8_t bgra[4 * 2 * 4]; + for (size_t i = 0; i < sizeof(bgra); i++) bgra[i] = (uint8_t)(i * 9); + struct st_frame b = {0}; + b.fmt = ST_FRAME_FMT_BGRA; /* not handled by the fused scaler */ + b.width = 4; + b.height = 2; + b.addr[0] = bgra; + b.data_size = sizeof(bgra); + display_frame_x11(&d, &b); + CHECK(memcmp(px(&d, 7, 7), bgra + (1 * 4 + 3) * 4, 4) == 0); + + frame_alloc(&f, ST_FRAME_FMT_YUV444PLANAR10LE, 8, 8, 0); + f.width = 0; /* no size: last size is kept, conversion fails, animated placeholder */ + f.height = 0; + display_frame_x11(&d, &f); + frame_free(&f); + display_frame_x11(&d, NULL); /* waiting-for-video placeholder */ + cleanup_x11_display(&d); +} + +static void test_mirror_copy(void) { + struct x11_display_ctx a, b, c; + REQUIRE(init_x11_display(&a, 6, 4, 0, NULL, false, NULL) == 0); + REQUIRE(init_x11_display(&b, 6, 4, 1, NULL, false, NULL) == 0); + REQUIRE(init_x11_display(&c, 3, 4, 2, NULL, false, NULL) == 0); + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_YUV444PLANAR10LE, 6, 4, 235 << 2); + display_frame_x11(&a, &f); + CHECK(display_copy_x11(&b, &a)); + CHECK(memcmp(a.image_data, b.image_data, 6 * 4 * 4) == 0); + CHECK(!display_copy_x11(&c, &a)); /* different size */ + frame_free(&f); + cleanup_x11_display(&c); + cleanup_x11_display(&b); + cleanup_x11_display(&a); +} + +/* The window manager (here: us) resizes the window: the image follows. */ +static void test_resize_and_non_shm_buffers(void) { + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 8, 4, 0, NULL, false, NULL) == 0); + d.use_shm = false; /* next buffer uses plain XPutImage */ + XResizeWindow(g_dpy, win_of(&d), 12, 6); + pump(&d); + CHECK_EQ_INT(d.width, 12); + CHECK_EQ_INT(d.height, 6); + CHECK(d.shminfo == NULL); + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 12, 6, 235 << 2); + display_frame_x11(&d, &f); + CHECK(px(&d, 11, 5)[1] == 255); + frame_free(&f); + XResizeWindow(g_dpy, win_of(&d), 12, 6); /* same size: nothing to do */ + pump(&d); + CHECK_EQ_INT(d.width, 12); + cleanup_x11_display(&d); +} + +/* Fullscreen on an output (by name and by index) and Escape toggling. */ +static void test_fullscreen_output_and_escape(void) { + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 16, 16, 0, g_output, true, NULL) == 0); + CHECK_EQ_STR(d.output_name, g_output); + CHECK_EQ_INT(d.wall_kind, X11_WALL_FULLSCREEN); + CHECK(d.width == (uint32_t)d.mon_w && d.height == (uint32_t)d.mon_h); + + send_key(win_of(&d), true); /* Escape: normal window */ + pump(&d); + CHECK(d.windowed); + send_key(win_of(&d), true); /* held key repeating: ignored */ + pump(&d); + CHECK(d.windowed); + send_key(win_of(&d), false); + send_key(win_of(&d), true); /* fresh press: back to wall mode */ + send_key(win_of(&d), false); + pump(&d); + CHECK(!d.windowed); + cleanup_x11_display(&d); + + struct x11_display_ctx by_index; + REQUIRE(init_x11_display(&by_index, 16, 16, 0, "", true, NULL) == 0); + CHECK_EQ_STR(by_index.output_name, g_output); + cleanup_x11_display(&by_index); +} + +static void test_missing_output(void) { + struct x11_display_ctx d; + CHECK_EQ_INT(init_x11_display(&d, 16, 16, 0, "NO-SUCH-OUTPUT", true, NULL), -1); + CHECK_EQ_INT(init_x11_display(&d, 16, 16, 7, NULL, true, NULL), -1); /* no 8th monitor */ + CHECK(!d.initialized); +} + +/* Fixed window on an output; Escape leaves and re-enters wall mode, including + * the WM-release (ReparentNotify) and timeout paths. */ +static void test_fixed_window_and_escape(void) { + struct crop_config rect = {10, 20, 32, 16}; + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 64, 64, 0, g_output, true, &rect) == 0); + CHECK_EQ_INT(d.wall_kind, X11_WALL_FIXED); + CHECK_EQ_INT(d.width, 32); + CHECK_EQ_INT(d.height, 16); + CHECK_EQ_INT(d.out_x, 10); + CHECK_EQ_INT(d.out_y, 20); + + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 32, 16, 235 << 2); + display_frame_x11(&d, &f); + if (d.depth == 32) CHECK(px(&d, 0, 0)[3] == 255); /* ARGB window stays opaque */ + frame_free(&f); + + send_key(win_of(&d), true); + send_key(win_of(&d), false); + pump(&d); + CHECK(d.windowed); + + send_key(win_of(&d), true); /* back: waits for the WM to release the window */ + send_key(win_of(&d), false); + pump(&d); + CHECK(d.wall_pending); + send_key(win_of(&d), true); /* toggle while pending: ignored */ + send_key(win_of(&d), false); + pump(&d); + CHECK(d.wall_pending); + send_reparent_to_root(win_of(&d)); + pump(&d); + CHECK(!d.wall_pending); + CHECK(!d.windowed); + + send_key(win_of(&d), true); /* windowed again, then back via the 1 s timeout */ + send_key(win_of(&d), false); + pump(&d); + send_key(win_of(&d), true); + send_key(win_of(&d), false); + pump(&d); + CHECK(d.wall_pending); + sleep_ms(1100); + process_x11_events(&d); + CHECK(!d.wall_pending); + cleanup_x11_display(&d); + + struct crop_config big = {0, 0, 100000, 16}; /* larger than the output: warning only */ + REQUIRE(init_x11_display(&d, 16, 16, 0, g_output, true, &big) == 0); + cleanup_x11_display(&d); +} + +/* Composite window: crop and strip writes, expose, resize to scaled and back. */ +static void test_composite_window(void) { + struct composite_x11_ctx c; + REQUIRE(init_composite_x11_display(&c, 8, 4, 1) == 0); + CHECK(c.initialized); + + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 4, 4, 255 << 2); /* white */ + struct crop_config crop = {6, 2, 4, 4}; /* partly outside: clamped */ + composite_write_crop(&c, &f, &crop); + const uint8_t* img = (const uint8_t*)c.image_data; + CHECK(img[(3 * 8 + 7) * 4] == 255); /* (7,3) written */ + CHECK(img[(1 * 8 + 7) * 4] == 0); /* (7,1) above the crop untouched */ + frame_free(&f); + + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 8, 4, 128 << 2); + composite_write_strip(&c, &f, 0); + CHECK(img[0] == 128); + frame_free(&f); + + Window w = (Window)(uintptr_t)c.window; + send_expose(w); + XResizeWindow(g_dpy, w, 16, 8); + XSync(g_dpy, False); + sleep_ms(30); + process_composite_x11_events(&c); + CHECK_EQ_INT(c.win_width, 16); + CHECK(c.scaled_ximage != NULL); + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 8, 4, 64 << 2); + composite_write_strip(&c, &f, 0); /* flush through the scaled image */ + CHECK(((const uint8_t*)c.scaled_image_data)[(7 * 16 + 15) * 4] == 64); + frame_free(&f); + + XResizeWindow(g_dpy, w, 8, 4); /* native size again: scaled image dropped */ + XSync(g_dpy, False); + sleep_ms(30); + process_composite_x11_events(&c); + CHECK(c.scaled_ximage == NULL); + cleanup_composite_x11_display(&c); + CHECK(!c.initialized); +} + +/* A session alone at a 2-session barrier would wait forever; abort releases it. */ +static void test_composite_abort_releases_waiters(void) { + composite_abort(NULL); + struct composite_x11_ctx c; + REQUIRE(init_composite_x11_display(&c, 4, 4, 2) == 0); + composite_abort(&c); + struct st_frame f; + frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 4, 4, 255 << 2); + struct crop_config crop = {0, 0, 4, 4}; + composite_write_crop(&c, &f, &crop); /* returns instead of blocking */ + composite_write_strip(&c, &f, 0); + CHECK(((const uint8_t*)c.image_data)[0] == 255); + frame_free(&f); + cleanup_composite_x11_display(&c); +} + +/* X protocol errors are logged, not fatal. */ +static void test_x_error_is_logged(void) { + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 4, 4, 0, NULL, false, NULL) == 0); /* installs the handler */ + XMapWindow(g_dpy, (Window)0x1fffff0); /* BadWindow */ + XSync(g_dpy, False); + cleanup_x11_display(&d); +} + +/* Close button on any window (session or composite) asks the app to stop. */ +static void test_close_button(void) { + struct x11_display_ctx d; + REQUIRE(init_x11_display(&d, 4, 4, 0, NULL, false, NULL) == 0); + CHECK(!x11_close_requested()); + send_close(win_of(&d)); + pump(&d); + CHECK(x11_close_requested()); + cleanup_x11_display(&d); + + struct composite_x11_ctx c; + REQUIRE(init_composite_x11_display(&c, 4, 4, 1) == 0); + send_close((Window)(uintptr_t)c.window); + sleep_ms(20); + process_composite_x11_events(&c); + CHECK(x11_close_requested()); + cleanup_composite_x11_display(&c); +} + +static int find_first_output(void) { + int n = 0; + Window root = DefaultRootWindow(g_dpy); + XRRMonitorInfo* mons = XRRGetMonitors(g_dpy, root, True, &n); + XRRScreenResources* res = XRRGetScreenResourcesCurrent(g_dpy, root); + if (n > 0 && mons[0].noutput > 0 && res) { + XRROutputInfo* info = XRRGetOutputInfo(g_dpy, res, mons[0].outputs[0]); + if (info) { + snprintf(g_output, sizeof(g_output), "%s", info->name); + XRRFreeOutputInfo(info); + } + } + if (res) XRRFreeScreenResources(res); + if (mons) XRRFreeMonitors(mons); + return g_output[0] ? 0 : -1; +} + +int main(void) { + XInitThreads(); + const char* env = getenv("DISPLAY"); + if (!env || !(g_dpy = XOpenDisplay(NULL)) || find_first_output() != 0) { + fprintf(stderr, "needs an X server (run under xvfb-run)\n"); + return 77; /* meson: skipped */ + } + snprintf(g_display_env, sizeof(g_display_env), "%s", env); + logger_set_level(LOG_LEVEL_ERROR + 1); + scratch_enter("x11"); + + RUN_TEST(test_init_without_x_server); + RUN_TEST(test_null_and_uninitialised_are_ignored); + RUN_TEST(test_normal_window_direct_frame); + RUN_TEST(test_frame_scaling_and_fallbacks); + RUN_TEST(test_mirror_copy); + RUN_TEST(test_resize_and_non_shm_buffers); + RUN_TEST(test_fullscreen_output_and_escape); + RUN_TEST(test_missing_output); + RUN_TEST(test_fixed_window_and_escape); + RUN_TEST(test_composite_window); + RUN_TEST(test_composite_abort_releases_waiters); + RUN_TEST(test_x_error_is_logged); + RUN_TEST(test_close_button); + + XCloseDisplay(g_dpy); + scratch_leave(); + return test_summary(); +} diff --git a/config/tx_1session_12bit.json b/dvledtx/config/tx_1session_12bit.json similarity index 100% rename from config/tx_1session_12bit.json rename to dvledtx/config/tx_1session_12bit.json diff --git a/config/tx_2k_2nic.json b/dvledtx/config/tx_2k_2nic.json similarity index 100% rename from config/tx_2k_2nic.json rename to dvledtx/config/tx_2k_2nic.json diff --git a/config/tx_2k_multi_session.json b/dvledtx/config/tx_2k_multi_session.json similarity index 100% rename from config/tx_2k_multi_session.json rename to dvledtx/config/tx_2k_multi_session.json diff --git a/config/tx_2k_single_session.json b/dvledtx/config/tx_2k_single_session.json similarity index 100% rename from config/tx_2k_single_session.json rename to dvledtx/config/tx_2k_single_session.json diff --git a/config/tx_4k_multi_session.json b/dvledtx/config/tx_4k_multi_session.json similarity index 100% rename from config/tx_4k_multi_session.json rename to dvledtx/config/tx_4k_multi_session.json diff --git a/config/tx_4k_single_session.json b/dvledtx/config/tx_4k_single_session.json similarity index 100% rename from config/tx_4k_single_session.json rename to dvledtx/config/tx_4k_single_session.json diff --git a/config/tx_fullhd_multi_nic.json b/dvledtx/config/tx_fullhd_multi_nic.json similarity index 100% rename from config/tx_fullhd_multi_nic.json rename to dvledtx/config/tx_fullhd_multi_nic.json diff --git a/config/tx_fullhd_multi_session.json b/dvledtx/config/tx_fullhd_multi_session.json similarity index 100% rename from config/tx_fullhd_multi_session.json rename to dvledtx/config/tx_fullhd_multi_session.json diff --git a/config/tx_fullhd_screen_capture.json b/dvledtx/config/tx_fullhd_screen_capture.json similarity index 100% rename from config/tx_fullhd_screen_capture.json rename to dvledtx/config/tx_fullhd_screen_capture.json diff --git a/config/tx_fullhd_screen_capture_multi_session.json b/dvledtx/config/tx_fullhd_screen_capture_multi_session.json similarity index 100% rename from config/tx_fullhd_screen_capture_multi_session.json rename to dvledtx/config/tx_fullhd_screen_capture_multi_session.json diff --git a/config/tx_fullhd_single_session.json b/dvledtx/config/tx_fullhd_single_session.json similarity index 100% rename from config/tx_fullhd_single_session.json rename to dvledtx/config/tx_fullhd_single_session.json diff --git a/config/tx_gbrp12le_pf_8session.json b/dvledtx/config/tx_gbrp12le_pf_8session.json similarity index 100% rename from config/tx_gbrp12le_pf_8session.json rename to dvledtx/config/tx_gbrp12le_pf_8session.json diff --git a/fuzz/corpus/empty_object.json b/dvledtx/fuzz/corpus/empty_object.json similarity index 100% rename from fuzz/corpus/empty_object.json rename to dvledtx/fuzz/corpus/empty_object.json diff --git a/fuzz/corpus/fmt_gbrp10le.json b/dvledtx/fuzz/corpus/fmt_gbrp10le.json similarity index 100% rename from fuzz/corpus/fmt_gbrp10le.json rename to dvledtx/fuzz/corpus/fmt_gbrp10le.json diff --git a/fuzz/corpus/fmt_yuv422p12le.json b/dvledtx/fuzz/corpus/fmt_yuv422p12le.json similarity index 100% rename from fuzz/corpus/fmt_yuv422p12le.json rename to dvledtx/fuzz/corpus/fmt_yuv422p12le.json diff --git a/fuzz/corpus/fmt_yuv444p10le.json b/dvledtx/fuzz/corpus/fmt_yuv444p10le.json similarity index 100% rename from fuzz/corpus/fmt_yuv444p10le.json rename to dvledtx/fuzz/corpus/fmt_yuv444p10le.json diff --git a/fuzz/corpus/malformed.json b/dvledtx/fuzz/corpus/malformed.json similarity index 100% rename from fuzz/corpus/malformed.json rename to dvledtx/fuzz/corpus/malformed.json diff --git a/fuzz/corpus/multi_nic.json b/dvledtx/fuzz/corpus/multi_nic.json similarity index 100% rename from fuzz/corpus/multi_nic.json rename to dvledtx/fuzz/corpus/multi_nic.json diff --git a/fuzz/corpus/multi_session.json b/dvledtx/fuzz/corpus/multi_session.json similarity index 100% rename from fuzz/corpus/multi_session.json rename to dvledtx/fuzz/corpus/multi_session.json diff --git a/fuzz/corpus/ptp_config.json b/dvledtx/fuzz/corpus/ptp_config.json similarity index 100% rename from fuzz/corpus/ptp_config.json rename to dvledtx/fuzz/corpus/ptp_config.json diff --git a/fuzz/corpus/screen_capture.json b/dvledtx/fuzz/corpus/screen_capture.json similarity index 100% rename from fuzz/corpus/screen_capture.json rename to dvledtx/fuzz/corpus/screen_capture.json diff --git a/fuzz/corpus/valid_config.json b/dvledtx/fuzz/corpus/valid_config.json similarity index 100% rename from fuzz/corpus/valid_config.json rename to dvledtx/fuzz/corpus/valid_config.json diff --git a/fuzz/fuzz_config_reader.c b/dvledtx/fuzz/fuzz_config_reader.c similarity index 100% rename from fuzz/fuzz_config_reader.c rename to dvledtx/fuzz/fuzz_config_reader.c diff --git a/fuzz/fuzz_config_reader_libfuzzer.c b/dvledtx/fuzz/fuzz_config_reader_libfuzzer.c similarity index 100% rename from fuzz/fuzz_config_reader_libfuzzer.c rename to dvledtx/fuzz/fuzz_config_reader_libfuzzer.c diff --git a/include/app_context.h b/dvledtx/include/app_context.h similarity index 100% rename from include/app_context.h rename to dvledtx/include/app_context.h diff --git a/include/core/session_manager.h b/dvledtx/include/core/session_manager.h similarity index 100% rename from include/core/session_manager.h rename to dvledtx/include/core/session_manager.h diff --git a/include/ffmpeg/ffmpeg_decoder.h b/dvledtx/include/ffmpeg/ffmpeg_decoder.h similarity index 100% rename from include/ffmpeg/ffmpeg_decoder.h rename to dvledtx/include/ffmpeg/ffmpeg_decoder.h diff --git a/include/ffmpeg/ffmpeg_frame_handler.h b/dvledtx/include/ffmpeg/ffmpeg_frame_handler.h similarity index 100% rename from include/ffmpeg/ffmpeg_frame_handler.h rename to dvledtx/include/ffmpeg/ffmpeg_frame_handler.h diff --git a/include/ffmpeg/ffmpeg_tx.h b/dvledtx/include/ffmpeg/ffmpeg_tx.h similarity index 100% rename from include/ffmpeg/ffmpeg_tx.h rename to dvledtx/include/ffmpeg/ffmpeg_tx.h diff --git a/include/mtl/mtl_tx.h b/dvledtx/include/mtl/mtl_tx.h similarity index 100% rename from include/mtl/mtl_tx.h rename to dvledtx/include/mtl/mtl_tx.h diff --git a/include/util/config_reader.h b/dvledtx/include/util/config_reader.h similarity index 100% rename from include/util/config_reader.h rename to dvledtx/include/util/config_reader.h diff --git a/include/util/logger.h b/dvledtx/include/util/logger.h similarity index 100% rename from include/util/logger.h rename to dvledtx/include/util/logger.h diff --git a/dvledtx/meson.build b/dvledtx/meson.build new file mode 100644 index 0000000..659652c --- /dev/null +++ b/dvledtx/meson.build @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright 2026 Intel Corporation + +# TX path selection — see the root meson_options.txt for details. +enable_mtl_tx = get_option('enable_mtl_tx') +tx_c_args = ['-DALLOW_EXPERIMENTAL_API', '-D_FORTIFY_SOURCE=2', '-O2'] +if enable_mtl_tx + tx_c_args += ['-DENABLE_MTL_TX'] +endif + +tx_deps = [m_dep, pthread_dep, libavformat_dep, libavcodec_dep, libavutil_dep, + libswscale_dep, avdevice_dep, mtl_dep] + +executable('dvledtx', + sources: [ + 'src/main.c', + 'src/util/config_reader.c', + 'src/core/session_manager.c', + 'src/ffmpeg/ffmpeg_decoder.c', + 'src/ffmpeg/ffmpeg_frame_handler.c', + 'src/ffmpeg/ffmpeg_tx.c', + 'src/mtl/mtl_tx.c', + 'src/util/logger.c', + ], + include_directories: include_directories('include'), + dependencies: tx_deps, + c_args: tx_c_args, + link_args: ['-s'], + build_rpath: '', + install: false +) + +subdir('tests') diff --git a/src/core/session_manager.c b/dvledtx/src/core/session_manager.c similarity index 100% rename from src/core/session_manager.c rename to dvledtx/src/core/session_manager.c diff --git a/src/ffmpeg/ffmpeg_decoder.c b/dvledtx/src/ffmpeg/ffmpeg_decoder.c similarity index 100% rename from src/ffmpeg/ffmpeg_decoder.c rename to dvledtx/src/ffmpeg/ffmpeg_decoder.c diff --git a/src/ffmpeg/ffmpeg_frame_handler.c b/dvledtx/src/ffmpeg/ffmpeg_frame_handler.c similarity index 100% rename from src/ffmpeg/ffmpeg_frame_handler.c rename to dvledtx/src/ffmpeg/ffmpeg_frame_handler.c diff --git a/src/ffmpeg/ffmpeg_tx.c b/dvledtx/src/ffmpeg/ffmpeg_tx.c similarity index 100% rename from src/ffmpeg/ffmpeg_tx.c rename to dvledtx/src/ffmpeg/ffmpeg_tx.c diff --git a/src/main.c b/dvledtx/src/main.c similarity index 100% rename from src/main.c rename to dvledtx/src/main.c diff --git a/src/mtl/mtl_tx.c b/dvledtx/src/mtl/mtl_tx.c similarity index 100% rename from src/mtl/mtl_tx.c rename to dvledtx/src/mtl/mtl_tx.c diff --git a/src/util/config_reader.c b/dvledtx/src/util/config_reader.c similarity index 100% rename from src/util/config_reader.c rename to dvledtx/src/util/config_reader.c diff --git a/src/util/logger.c b/dvledtx/src/util/logger.c similarity index 100% rename from src/util/logger.c rename to dvledtx/src/util/logger.c diff --git a/dvledtx/tests/meson.build b/dvledtx/tests/meson.build new file mode 100644 index 0000000..e5cdf8b --- /dev/null +++ b/dvledtx/tests/meson.build @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright 2026 Intel Corporation + +cmocka_dep = dependency('cmocka', required: false, method: 'pkg-config') +if not cmocka_dep.found() + subdir_done() +endif + +tx_test_deps = [m_dep, pthread_dep, libavformat_dep, libavcodec_dep, + libavutil_dep, libswscale_dep, avdevice_dep, cmocka_dep] +tx_fixture_dir = meson.current_source_dir() / '../config' +tx_test_inc = include_directories('../include', '/usr/local/include/mtl') +tx_test_c_args = ['-DALLOW_EXPERIMENTAL_API'] + +tx_test_config_reader = executable('test_config_reader', + sources: ['test_config_reader.c', '../src/util/config_reader.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args + ['-DFIXTURE_DIR="' + tx_fixture_dir + '"'], + install: false, + build_by_default: false +) +test('config_reader', tx_test_config_reader) + +tx_test_logger = executable('test_logger', + sources: ['test_logger.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: [m_dep, pthread_dep, cmocka_dep], + install: false, + build_by_default: false +) +test('logger', tx_test_logger) + +tx_test_ffmpeg_decoder = executable('test_ffmpeg_decoder', + sources: ['test_ffmpeg_decoder.c', '../src/ffmpeg/ffmpeg_decoder.c', + '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + install: false, + build_by_default: false +) +test('ffmpeg_decoder', tx_test_ffmpeg_decoder) + +tx_test_ffmpeg_frame_handler = executable('test_ffmpeg_frame_handler', + sources: ['test_ffmpeg_frame_handler.c', '../src/ffmpeg/ffmpeg_frame_handler.c', + '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + install: false, + build_by_default: false +) +test('ffmpeg_frame_handler', tx_test_ffmpeg_frame_handler) + +tx_test_ffmpeg_tx = executable('test_ffmpeg_tx', + sources: ['test_ffmpeg_tx.c', '../src/ffmpeg/ffmpeg_tx.c', + '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + link_args: ['-Wl,--wrap=av_guess_format'], + install: false, + build_by_default: false +) +test('ffmpeg_tx', tx_test_ffmpeg_tx) + +# session_manager.c compiled in; ffmpeg_decoder.c excluded — mocks provided in the test file. +tx_test_session_manager = executable('test_session_manager', + sources: ['test_session_manager.c', '../src/core/session_manager.c', + '../src/mtl/mtl_tx.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps + (mtl_dep.found() ? [mtl_dep] : []), + c_args: tx_test_c_args, + install: false, + build_by_default: false +) +test('session_manager', tx_test_session_manager) + +# main.c is included directly in the test file to expose static functions. +tx_test_main = executable('test_main', + sources: ['test_main.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + install: false, + build_by_default: false +) +test('main', tx_test_main) + +tx_test_ffmpeg_decoder_mock = executable('test_ffmpeg_decoder_mock', + sources: ['test_ffmpeg_decoder_mock.c', '../src/ffmpeg/ffmpeg_decoder.c', + '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + link_args: ['-Wl,--wrap=av_guess_format'], + install: false, + build_by_default: false +) +test('ffmpeg_decoder_mock', tx_test_ffmpeg_decoder_mock) + +tx_test_ffmpeg_tx_mock = executable('test_ffmpeg_tx_mock', + sources: ['test_ffmpeg_tx_mock.c', '../src/ffmpeg/ffmpeg_decoder.c', + '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/ffmpeg/ffmpeg_tx.c', + '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + link_args: ['-Wl,--wrap=av_guess_format'], + install: false, + build_by_default: false +) +test('ffmpeg_tx_mock', tx_test_ffmpeg_tx_mock) + +tx_test_ffmpeg_watchdog = executable('test_ffmpeg_watchdog', + sources: ['test_ffmpeg_watchdog.c', '../src/ffmpeg/ffmpeg_decoder.c', + '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'], + include_directories: tx_test_inc, + dependencies: tx_test_deps, + c_args: tx_test_c_args, + link_args: ['-Wl,--wrap=av_read_frame', '-Wl,--wrap=av_guess_format'], + install: false, + build_by_default: false +) +test('ffmpeg_watchdog', tx_test_ffmpeg_watchdog) + +if mtl_dep.found() + tx_test_mtl = executable('test_mtl_tx', + sources: ['test_mtl_tx.c', '../src/mtl/mtl_tx.c', '../src/util/logger.c'], + include_directories: [tx_test_inc, include_directories('/usr/local/include/mtl')], + dependencies: tx_test_deps + [mtl_dep], + c_args: tx_test_c_args + ['-DENABLE_MTL_TX'], + install: false, + build_by_default: false + ) + test('mtl_tx', tx_test_mtl) + + tx_test_mtl_stub = executable('test_mtl_tx_stub', + sources: ['test_mtl_tx_stub.c', '../src/mtl/mtl_tx.c', '../src/util/logger.c'], + include_directories: [tx_test_inc, include_directories('/usr/local/include/mtl')], + dependencies: tx_test_deps + [mtl_dep], + c_args: tx_test_c_args + ['-DENABLE_MTL_TX'], + link_args: ['-Wl,--wrap=mtl_init', '-Wl,--wrap=mtl_uninit', + '-Wl,--wrap=mtl_pmd_by_port_name', '-Wl,--wrap=st20p_tx_create', + '-Wl,--wrap=st20p_tx_free', '-Wl,--wrap=st20p_tx_frame_size', + '-Wl,--wrap=st20p_tx_get_frame', '-Wl,--wrap=st20p_tx_put_frame'], + install: false, + build_by_default: false + ) + test('mtl_tx_stub', tx_test_mtl_stub) +endif diff --git a/tests/test_config_reader.c b/dvledtx/tests/test_config_reader.c similarity index 100% rename from tests/test_config_reader.c rename to dvledtx/tests/test_config_reader.c diff --git a/tests/test_ffmpeg_decoder.c b/dvledtx/tests/test_ffmpeg_decoder.c similarity index 100% rename from tests/test_ffmpeg_decoder.c rename to dvledtx/tests/test_ffmpeg_decoder.c diff --git a/tests/test_ffmpeg_decoder_mock.c b/dvledtx/tests/test_ffmpeg_decoder_mock.c similarity index 100% rename from tests/test_ffmpeg_decoder_mock.c rename to dvledtx/tests/test_ffmpeg_decoder_mock.c diff --git a/tests/test_ffmpeg_frame_handler.c b/dvledtx/tests/test_ffmpeg_frame_handler.c similarity index 100% rename from tests/test_ffmpeg_frame_handler.c rename to dvledtx/tests/test_ffmpeg_frame_handler.c diff --git a/tests/test_ffmpeg_tx.c b/dvledtx/tests/test_ffmpeg_tx.c similarity index 100% rename from tests/test_ffmpeg_tx.c rename to dvledtx/tests/test_ffmpeg_tx.c diff --git a/tests/test_ffmpeg_tx_mock.c b/dvledtx/tests/test_ffmpeg_tx_mock.c similarity index 100% rename from tests/test_ffmpeg_tx_mock.c rename to dvledtx/tests/test_ffmpeg_tx_mock.c diff --git a/tests/test_ffmpeg_watchdog.c b/dvledtx/tests/test_ffmpeg_watchdog.c similarity index 100% rename from tests/test_ffmpeg_watchdog.c rename to dvledtx/tests/test_ffmpeg_watchdog.c diff --git a/tests/test_logger.c b/dvledtx/tests/test_logger.c similarity index 100% rename from tests/test_logger.c rename to dvledtx/tests/test_logger.c diff --git a/tests/test_main.c b/dvledtx/tests/test_main.c similarity index 100% rename from tests/test_main.c rename to dvledtx/tests/test_main.c diff --git a/tests/test_mtl_tx.c b/dvledtx/tests/test_mtl_tx.c similarity index 100% rename from tests/test_mtl_tx.c rename to dvledtx/tests/test_mtl_tx.c diff --git a/tests/test_mtl_tx_stub.c b/dvledtx/tests/test_mtl_tx_stub.c similarity index 100% rename from tests/test_mtl_tx_stub.c rename to dvledtx/tests/test_mtl_tx_stub.c diff --git a/tests/test_session_manager.c b/dvledtx/tests/test_session_manager.c similarity index 100% rename from tests/test_session_manager.c rename to dvledtx/tests/test_session_manager.c diff --git a/meson.build b/meson.build index a85c8b2..53502ea 100644 --- a/meson.build +++ b/meson.build @@ -16,38 +16,14 @@ libswscale_dep = dependency('libswscale', required: true) # TX path selection — see meson_options.txt for details. enable_mtl_tx = get_option('enable_mtl_tx') -# MTL is a required dependency for both TX paths — see src/mtl/mtl_tx.c. +# MTL is required by both transmit and receive applications. mtl_dep = dependency('mtl', required: true) -c_args = ['-DALLOW_EXPERIMENTAL_API', '-D_FORTIFY_SOURCE=2', '-O2'] # libavdevice is needed regardless of the TX path: the mtl_st20p muxer TX # path uses it directly, and the MTL-native TX path still needs it for the # x11grab screen-capture input decoder (avdevice_register_all()). avdevice_dep = dependency('libavdevice', required: true) -if enable_mtl_tx - c_args += ['-DENABLE_MTL_TX'] -endif - -all_deps = [m_dep, pthread_dep, - libavformat_dep, libavcodec_dep, libavutil_dep, libswscale_dep, - avdevice_dep, mtl_dep] - -executable('dvledtx', - sources: [ - 'src/main.c', - 'src/util/config_reader.c', - 'src/core/session_manager.c', - 'src/ffmpeg/ffmpeg_decoder.c', - 'src/ffmpeg/ffmpeg_frame_handler.c', - 'src/ffmpeg/ffmpeg_tx.c', - 'src/mtl/mtl_tx.c', - 'src/util/logger.c' - ], - include_directories: include_directories('include'), - dependencies: all_deps, - c_args: c_args, - link_args: ['-s'], - build_rpath: '', - install: false -) +# Each application owns its Meson definition and its tests subdirectory. +subdir('dvledtx') +subdir('dvledrx') diff --git a/scripts/build.sh b/scripts/build.sh index bcb6a87..6efc7e7 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright 2026 Intel Corporation -# Build script for dvledtx +# Build script for dvledtx and dvledrx # Usage: bash scripts/build.sh [meson options] # e.g. bash scripts/build.sh -Denable_mtl_tx=true @@ -11,7 +11,7 @@ set -e REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -echo "Building dvledtx..." +echo "Building dvledtx and dvledrx..." echo "Cleaning previous build..." rm -rf build @@ -19,11 +19,15 @@ rm -rf build echo "Setting up build directory..." meson setup build "$@" -echo "Compiling dvledtx..." +echo "Compiling applications..." ninja -C build +test -x ./build/dvledtx/dvledtx +test -x ./build/dvledrx/dvledrx + echo "Build completed successfully!" echo "" -echo "To run the application:" -echo " ./build/dvledtx --help" +echo "To run the applications:" +echo " ./build/dvledtx/dvledtx --help" +echo " ./build/dvledrx/dvledrx --help" echo "" diff --git a/scripts/test.sh b/scripts/test.sh index 9ccfc11..fd3a570 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -2,18 +2,17 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright 2026 Intel Corporation -# Unit test runner for dvledtx +# Unit test runner for dvledtx and dvledrx # Usage: bash scripts/test.sh [--no-coverage] # -# Runs all cmocka unit tests under tests/ and prints a pass/fail summary. +# Runs both applications' unit tests and prints a pass/fail summary. # When gcovr is available (and --no-coverage is not passed), a per-file -# line coverage report is printed after the test results. +# line coverage report is printed for both source trees. set -e REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TESTS_DIR="$REPO_ROOT/tests" -BUILD_DIR="$TESTS_DIR/build" +BUILD_DIR="$REPO_ROOT/build-tests" COVERAGE=true if [[ "${1:-}" == "--no-coverage" ]]; then @@ -36,15 +35,14 @@ if $COVERAGE && [[ -z "$GCOVR" ]]; then fi # ── Build ────────────────────────────────────────────────────────────────── -echo "Setting up test build..." -cd "$TESTS_DIR" +echo "Setting up combined test build..." rm -rf "$BUILD_DIR" MESON_ARGS=() $COVERAGE && MESON_ARGS+=("-Db_coverage=true") -meson setup build "${MESON_ARGS[@]}" >/dev/null -ninja -C build >/dev/null +meson setup "$BUILD_DIR" "$REPO_ROOT" "${MESON_ARGS[@]}" >/dev/null +ninja -C "$BUILD_DIR" >/dev/null # ── Detect a live X display for opt-in screen-capture (x11grab) tests ─────── # The screen-capture success tests run only when DVLED_TEST_DISPLAY is set to @@ -72,19 +70,21 @@ echo "" echo "Running unit tests..." echo "──────────────────────────────────────────────────────────────────────" set +e -meson test -C build --print-errorlogs 2>&1 +meson test -C "$BUILD_DIR" --print-errorlogs 2>&1 TEST_EXIT=$? set -e # ── Coverage ─────────────────────────────────────────────────────────────── if $COVERAGE; then echo "" - echo "Coverage report (src/ — main.c excluded)" + echo "Coverage report (dvledtx/src/ and dvledrx/src/ — main.c excluded)" echo "──────────────────────────────────────────────────────────────────────" "$GCOVR" \ - --root "$REPO_ROOT/src" \ - --filter "$REPO_ROOT/src/" \ - --exclude "$REPO_ROOT/src/main.c" \ + --root "$REPO_ROOT" \ + --filter "$REPO_ROOT/dvledtx/src/" \ + --filter "$REPO_ROOT/dvledrx/src/" \ + --exclude "$REPO_ROOT/dvledtx/src/main.c" \ + --exclude "$REPO_ROOT/dvledrx/src/main.c" \ --gcov-ignore-parse-errors=negative_hits.warn_once_per_file \ "$BUILD_DIR" \ --txt diff --git a/tests/meson.build b/tests/meson.build deleted file mode 100644 index aa6aa60..0000000 --- a/tests/meson.build +++ /dev/null @@ -1,212 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright 2026 Intel Corporation - -project('dvledtx-tests', 'c') - -cc = meson.get_compiler('c') - -m_dep = cc.find_library('m', required: true) -pthread_dep = cc.find_library('pthread', required: true) - -libavformat_dep = dependency('libavformat', required: true) -libavcodec_dep = dependency('libavcodec', required: true) -libavutil_dep = dependency('libavutil', required: true) -libswscale_dep = dependency('libswscale', required: true) -avdevice_dep = dependency('libavdevice', required: true) -mtl_dep = dependency('mtl', required: false) - -cmocka_dep = dependency('cmocka', required: false, method: 'pkg-config') - -if not cmocka_dep.found() - subdir_done() -endif - -all_deps = [m_dep, pthread_dep, - libavformat_dep, libavcodec_dep, libavutil_dep, libswscale_dep, - avdevice_dep] -fixture_dir = meson.project_source_root() / '../config' -test_inc = include_directories('../include', '/usr/local/include/mtl') -test_deps = all_deps + [cmocka_dep] -test_c_args = ['-DALLOW_EXPERIMENTAL_API'] - -test_config_reader = executable('test_config_reader', - sources: [ - 'test_config_reader.c', - '../src/util/config_reader.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args + ['-DFIXTURE_DIR="' + fixture_dir + '"'], - install: false -) -test('config_reader', test_config_reader) - -test_logger = executable('test_logger', - sources: [ - 'test_logger.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: [m_dep, pthread_dep, cmocka_dep], - install: false -) -test('logger', test_logger) - -test_ffmpeg_decoder = executable('test_ffmpeg_decoder', - sources: [ - 'test_ffmpeg_decoder.c', - '../src/ffmpeg/ffmpeg_decoder.c', - '../src/ffmpeg/ffmpeg_frame_handler.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - install: false -) -test('ffmpeg_decoder', test_ffmpeg_decoder) - -test_ffmpeg_frame_handler = executable('test_ffmpeg_frame_handler', - sources: [ - 'test_ffmpeg_frame_handler.c', - '../src/ffmpeg/ffmpeg_frame_handler.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - install: false -) -test('ffmpeg_frame_handler', test_ffmpeg_frame_handler) - -test_ffmpeg_tx = executable('test_ffmpeg_tx', - sources: [ - 'test_ffmpeg_tx.c', - '../src/ffmpeg/ffmpeg_tx.c', - '../src/ffmpeg/ffmpeg_frame_handler.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - link_args: ['-Wl,--wrap=av_guess_format'], - install: false -) -test('ffmpeg_tx', test_ffmpeg_tx) - -# session_manager.c compiled in; ffmpeg_decoder.c excluded — mocks provided in the test file. -test_session_manager = executable('test_session_manager', - sources: [ - 'test_session_manager.c', - '../src/core/session_manager.c', - '../src/mtl/mtl_tx.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps + (mtl_dep.found() ? [mtl_dep] : []), - c_args: test_c_args, - install: false -) -test('session_manager', test_session_manager) - -# main.c is included directly in the test file to expose static functions. -# session_manager.c and config_reader.c excluded — stubs provided in the test file. -test_main = executable('test_main', - sources: [ - 'test_main.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - install: false -) -test('main', test_main) - -# av_guess_format wrapped to return the 'null' muxer instead of mtl_st20p. -test_ffmpeg_decoder_mock = executable('test_ffmpeg_decoder_mock', - sources: [ - 'test_ffmpeg_decoder_mock.c', - '../src/ffmpeg/ffmpeg_decoder.c', - '../src/ffmpeg/ffmpeg_frame_handler.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - link_args: ['-Wl,--wrap=av_guess_format'], - install: false -) -test('ffmpeg_decoder_mock', test_ffmpeg_decoder_mock) - -test_ffmpeg_tx_mock = executable('test_ffmpeg_tx_mock', - sources: [ - 'test_ffmpeg_tx_mock.c', - '../src/ffmpeg/ffmpeg_decoder.c', - '../src/ffmpeg/ffmpeg_frame_handler.c', - '../src/ffmpeg/ffmpeg_tx.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - link_args: ['-Wl,--wrap=av_guess_format'], - install: false -) -test('ffmpeg_tx_mock', test_ffmpeg_tx_mock) - -# Watchdog tests: --wrap=av_read_frame to inject perpetual decode failure. -test_ffmpeg_watchdog = executable('test_ffmpeg_watchdog', - sources: [ - 'test_ffmpeg_watchdog.c', - '../src/ffmpeg/ffmpeg_decoder.c', - '../src/ffmpeg/ffmpeg_frame_handler.c', - '../src/util/logger.c', - ], - include_directories: test_inc, - dependencies: test_deps, - c_args: test_c_args, - link_args: ['-Wl,--wrap=av_read_frame', - '-Wl,--wrap=av_guess_format'], - install: false -) -test('ffmpeg_watchdog', test_ffmpeg_watchdog) - -if mtl_dep.found() - test_mtl_tx = executable('test_mtl_tx', - sources: [ - 'test_mtl_tx.c', - '../src/mtl/mtl_tx.c', - '../src/util/logger.c', - ], - include_directories: [test_inc, include_directories('/usr/local/include/mtl')], - dependencies: test_deps + [mtl_dep], - c_args: test_c_args + ['-DENABLE_MTL_TX'], - install: false - ) - test('mtl_tx', test_mtl_tx) - - # Stub-based tests: MTL library entry points are wrapped so the session - # lifecycle and frame-send paths run with no real MTL/DPDK hardware. - test_mtl_tx_stub = executable('test_mtl_tx_stub', - sources: [ - 'test_mtl_tx_stub.c', - '../src/mtl/mtl_tx.c', - '../src/util/logger.c', - ], - include_directories: [test_inc, include_directories('/usr/local/include/mtl')], - dependencies: test_deps + [mtl_dep], - c_args: test_c_args + ['-DENABLE_MTL_TX'], - link_args: ['-Wl,--wrap=mtl_init', - '-Wl,--wrap=mtl_uninit', - '-Wl,--wrap=mtl_pmd_by_port_name', - '-Wl,--wrap=st20p_tx_create', - '-Wl,--wrap=st20p_tx_free', - '-Wl,--wrap=st20p_tx_frame_size', - '-Wl,--wrap=st20p_tx_get_frame', - '-Wl,--wrap=st20p_tx_put_frame'], - install: false - ) - test('mtl_tx_stub', test_mtl_tx_stub) -endif From bcd97a7871df9ff0d4d9170ff51fa15e382acc12 Mon Sep 17 00:00:00 2001 From: roshan-ku Date: Mon, 19 Oct 2026 09:56:51 +0530 Subject: [PATCH 2/2] fix(dvledrx): resolve CodeQL high-severity alerts - frame_converter: compute the BGRA copy size in size_t so width * height * 4 cannot overflow 32-bit unsigned arithmetic. - logger: remove the lstat()-then-rename() race during rotation. The rename now refuses to replace an existing .rotating atomically (renameat2 RENAME_NOREPLACE, falling back to link()+unlink() on filesystems without it), and the ftell() fallback uses fstat() on the open descriptor instead of stat() on the path. The log stays open until the rename succeeds, so a failed rotation keeps appending as before. - test_config_reader: build test JSON through a bounds-checked append helper instead of accumulating unchecked snprintf() return values. --- dvledrx/src/frame_converter.c | 2 +- dvledrx/src/logger.c | 52 ++++++++++++++++++++---------- dvledrx/tests/test_config_reader.c | 47 ++++++++++++++++++--------- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/dvledrx/src/frame_converter.c b/dvledrx/src/frame_converter.c index a616e7d..d7fc2cb 100644 --- a/dvledrx/src/frame_converter.c +++ b/dvledrx/src/frame_converter.c @@ -104,7 +104,7 @@ int convert_frame_to_rgb(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t w switch (frame->fmt) { case ST_FRAME_FMT_BGRA: { /* MTL already converted to BGRA — direct memcpy, no pixel conversion needed */ - memcpy(rgb_buffer, frame->addr[0], width * height * 4); + memcpy(rgb_buffer, frame->addr[0], (size_t)width * height * 4); break; } diff --git a/dvledrx/src/logger.c b/dvledrx/src/logger.c index 3284b2e..af8cd37 100644 --- a/dvledrx/src/logger.c +++ b/dvledrx/src/logger.c @@ -2,6 +2,10 @@ * Copyright(c) 2026 Intel Corporation */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE /* renameat2() */ +#endif + #include "logger.h" #include @@ -234,6 +238,22 @@ static void* gz_worker(void* arg) { return NULL; } +/* Atomically rename 'from' to 'to', failing with EEXIST instead of replacing an + * existing 'to'. Avoids a separate existence check that could race. */ +static int rename_noreplace(const char* from, const char* to) { + if (renameat2(AT_FDCWD, from, AT_FDCWD, to, RENAME_NOREPLACE) == 0) return 0; + if (errno != EINVAL && errno != ENOSYS) return -1; + /* Filesystem without RENAME_NOREPLACE: link() also refuses an existing target. */ + if (link(from, to) != 0) return -1; + if (unlink(from) != 0) { + int err = errno; + unlink(to); + errno = err; + return -1; + } + return 0; +} + /* Must be called with g_log_lock held. */ static void start_archiver(void) { char* base = strdup(g_log_file_path); @@ -319,9 +339,9 @@ static void check_and_rotate(void) { long pos = ftell(g_log_file); if (pos < 0) { - /* ftell failed – try stat as fallback */ + /* ftell failed – try fstat on the open descriptor as fallback */ struct stat st; - if (stat(g_log_file_path, &st) == 0) + if (fstat(fileno(g_log_file), &st) == 0) pos = (long)st.st_size; else return; @@ -335,26 +355,24 @@ static void check_and_rotate(void) { char pending[PATH_MAX]; snprintf(pending, sizeof(pending), "%s.rotating", g_log_file_path); - struct stat pst; - if (lstat(pending, &pst) == 0) { - /* An earlier archive failed to compress: never overwrite it. Keep appending - * and retry compressing it at most every 10 s. */ - time_t now = time(NULL); - if (now - g_gz_last_retry >= 10) { - g_gz_last_retry = now; - start_archiver(); + /* Rename while the file is still open so a failed rotation keeps appending. */ + fflush(g_log_file); + if (rename_noreplace(g_log_file_path, pending) != 0) { + if (errno == EEXIST) { + /* An earlier archive failed to compress: never overwrite it. Keep appending + * and retry compressing it at most every 10 s. */ + time_t now = time(NULL); + if (now - g_gz_last_retry >= 10) { + g_gz_last_retry = now; + start_archiver(); + } + } else { + fprintf(g_log_file, "--- log rotation failed: %s ---\n", strerror(errno)); } return; } fclose(g_log_file); - g_log_file = NULL; - bool renamed = rename(g_log_file_path, pending) == 0; g_log_file = open_log_file(g_log_file_path); - if (!renamed) { - if (g_log_file) - fprintf(g_log_file, "--- log rotation failed: %s ---\n", strerror(errno)); - return; - } if (g_log_file) { fprintf(g_log_file, "--- log rotated: previous %ld bytes -> %s.1.gz (keeping %d archives) ---\n", pos, g_log_file_path, LOGGER_MAX_ARCHIVES); diff --git a/dvledrx/tests/test_config_reader.c b/dvledrx/tests/test_config_reader.c index de91b0d..21da028 100644 --- a/dvledrx/tests/test_config_reader.c +++ b/dvledrx/tests/test_config_reader.c @@ -9,12 +9,26 @@ #include "test_common.h" #include +#include #include "config_reader.h" #include "logger.h" static const char* g_config_dir; +/* Append formatted text at *len; fails the test instead of overflowing 'buf'. */ +__attribute__((format(printf, 4, 5))) +static bool appendf(char* buf, size_t size, size_t* len, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf + *len, size - *len, fmt, ap); + va_end(ap); + CHECK(n >= 0 && (size_t)n < size - *len); + if (n < 0 || (size_t)n >= size - *len) return false; + *len += (size_t)n; + return true; +} + /* Parse JSON text via a scratch file. */ static int parse_text(const char* json, struct rx_config* cfg) { if (write_file("cfg.json", json) != 0) return -99; @@ -183,21 +197,23 @@ static void test_parse_display_unknown_session(void) { static void test_parse_too_many_mirrors(void) { char json[2048]; struct rx_config c; - int n = snprintf(json, sizeof(json), "{ \"rx_sessions\": [ {} ], \"displays\": ["); + size_t n = 0; + REQUIRE(appendf(json, sizeof(json), &n, "{ \"rx_sessions\": [ {} ], \"displays\": [")); for (int d = 0; d < MAX_MIRRORS + 2; d++) - n += snprintf(json + n, sizeof(json) - n, - "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }", - d ? "," : "", d); - snprintf(json + n, sizeof(json) - n, " ] }"); + REQUIRE(appendf(json, sizeof(json), &n, + "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }", + d ? "," : "", d)); + REQUIRE(appendf(json, sizeof(json), &n, " ] }")); CHECK_EQ_INT(parse_text(json, &c), -1); /* exactly 1 primary + MAX_MIRRORS mirrors is fine */ - n = snprintf(json, sizeof(json), "{ \"rx_sessions\": [ {} ], \"displays\": ["); + n = 0; + REQUIRE(appendf(json, sizeof(json), &n, "{ \"rx_sessions\": [ {} ], \"displays\": [")); for (int d = 0; d < MAX_MIRRORS + 1; d++) - n += snprintf(json + n, sizeof(json) - n, - "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }", - d ? "," : "", d); - snprintf(json + n, sizeof(json) - n, " ] }"); + REQUIRE(appendf(json, sizeof(json), &n, + "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }", + d ? "," : "", d)); + REQUIRE(appendf(json, sizeof(json), &n, " ] }")); REQUIRE(parse_text(json, &c) == 0); CHECK_EQ_INT(c.rx_sessions[0].num_mirrors, MAX_MIRRORS); } @@ -232,13 +248,14 @@ static void test_parse_display_without_sessions_ignored(void) { static void test_parse_truncates_arrays(void) { char json[8192]; struct rx_config c; - int n = snprintf(json, sizeof(json), "{ \"interfaces\": ["); + size_t n = 0; + REQUIRE(appendf(json, sizeof(json), &n, "{ \"interfaces\": [")); for (int i = 0; i < MAX_INTERFACES + 2; i++) - n += snprintf(json + n, sizeof(json) - n, "%s{ \"name\": \"n%d\" }", i ? "," : "", i); - n += snprintf(json + n, sizeof(json) - n, "], \"rx_sessions\": ["); + REQUIRE(appendf(json, sizeof(json), &n, "%s{ \"name\": \"n%d\" }", i ? "," : "", i)); + REQUIRE(appendf(json, sizeof(json), &n, "], \"rx_sessions\": [")); for (int i = 0; i < MAX_RX_SESSIONS + 3; i++) - n += snprintf(json + n, sizeof(json) - n, "%s{ \"udp_port\": %d }", i ? "," : "", 20000 + i); - snprintf(json + n, sizeof(json) - n, "] }"); + REQUIRE(appendf(json, sizeof(json), &n, "%s{ \"udp_port\": %d }", i ? "," : "", 20000 + i)); + REQUIRE(appendf(json, sizeof(json), &n, "] }")); REQUIRE(parse_text(json, &c) == 0); CHECK_EQ_INT(c.num_interfaces, MAX_INTERFACES); CHECK_EQ_INT(c.num_rx_sessions, MAX_RX_SESSIONS);