diff --git a/.buildkite/main-tests.json b/.buildkite/main-tests.json index 8dc87ad15..329493971 100644 --- a/.buildkite/main-tests.json +++ b/.buildkite/main-tests.json @@ -2,7 +2,7 @@ "tests": [ { "test_name": "build-gnu", - "command": "RUSTFLAGS=\"-D warnings\" cargo build --release", + "command": "BINDGEN_EXTRA_CLANG_ARGS=\"-I/usr/include/$(uname -m)-linux-gnu\" RUSTFLAGS=\"-D warnings\" cargo build --release", "platform": [ "x86_64", "aarch64", @@ -11,7 +11,7 @@ }, { "test_name": "build-musl", - "command": "RUSTFLAGS=\"-D warnings\" cargo build --release --target {target_platform}-unknown-linux-musl --workspace --exclude vhost-device-gpu", + "command": "RUSTFLAGS=\"-D warnings\" cargo build --release --target {target_platform}-unknown-linux-musl --workspace --exclude vhost-device-gpu --exclude vhost-device-media", "platform": [ "x86_64", "aarch64" @@ -27,7 +27,7 @@ }, { "test_name": "unittests-gnu", - "command": "cargo test --all-features --workspace", + "command": "BINDGEN_EXTRA_CLANG_ARGS=\"-I/usr/include/$(uname -m)-linux-gnu\" cargo test --all-features --workspace", "platform": [ "x86_64", "aarch64", @@ -39,7 +39,7 @@ }, { "test_name": "unittests-musl", - "command": "cargo test --all-features --workspace --target {target_platform}-unknown-linux-musl --exclude vhost-device-gpu", + "command": "cargo test --all-features --workspace --target {target_platform}-unknown-linux-musl --exclude vhost-device-gpu --exclude vhost-device-media", "platform": [ "x86_64", "aarch64" @@ -50,7 +50,7 @@ }, { "test_name": "unittests-gnu-release", - "command": "cargo test --release --all-features --workspace", + "command": "BINDGEN_EXTRA_CLANG_ARGS=\"-I/usr/include/$(uname -m)-linux-gnu\" cargo test --release --all-features --workspace", "platform": [ "x86_64", "aarch64", @@ -62,7 +62,7 @@ }, { "test_name": "unittests-musl-release", - "command": "cargo test --release --all-features --workspace --target {target_platform}-unknown-linux-musl --exclude vhost-device-gpu", + "command": "cargo test --release --all-features --workspace --target {target_platform}-unknown-linux-musl --exclude vhost-device-gpu --exclude vhost-device-media", "platform": [ "x86_64", "aarch64" @@ -73,7 +73,7 @@ }, { "test_name": "clippy", - "command": "cargo clippy --workspace --bins --examples --benches --all-features --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks", + "command": "BINDGEN_EXTRA_CLANG_ARGS=\"-I/usr/include/$(uname -m)-linux-gnu\" cargo clippy --workspace --bins --examples --benches --all-features --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks", "platform": [ "x86_64", "aarch64", @@ -82,7 +82,7 @@ }, { "test_name": "check-warnings", - "command": "RUSTFLAGS=\"-D warnings\" cargo check --all-targets --all-features --workspace", + "command": "BINDGEN_EXTRA_CLANG_ARGS=\"-I/usr/include/$(uname -m)-linux-gnu\" RUSTFLAGS=\"-D warnings\" cargo check --all-targets --all-features --workspace", "platform": [ "x86_64", "aarch64", diff --git a/Cargo.lock b/Cargo.lock index d38f8b49e..e8c01f65f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -90,7 +90,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -135,6 +135,26 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -151,7 +171,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.2", "shlex", "syn 2.0.117", ] @@ -233,6 +253,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -326,7 +352,7 @@ dependencies = [ "encode_unicode", "libc 0.2.186", "unicode-width", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -447,6 +473,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -493,7 +530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc 0.2.186", - "windows-sys 0.61.1", + "windows-sys 0.52.0", ] [[package]] @@ -652,7 +689,7 @@ dependencies = [ "gobject-sys", "libc 0.2.186", "system-deps", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -978,9 +1015,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libc" -version = "1.0.0-alpha.1" +version = "1.0.0-alpha.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7222002e5385b4d9327755661e3847c970e8fbf9dea6da8c57f16e8cfbff53a8" +checksum = "e136bfa874086c78f34d6eba98c423fefe464ce57f38164d16893ba8ed358e70" [[package]] name = "libgpiod" @@ -1001,7 +1038,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae4417fe6c528d48e1982b8fc7fdd9999013065cb8b4978369c2e4fea69ad4df" dependencies = [ - "bindgen", + "bindgen 0.72.1", "system-deps", ] @@ -1038,7 +1075,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cc", "system-deps", ] @@ -1124,7 +1161,7 @@ dependencies = [ "libc 0.2.186", "log", "wasi", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1190,6 +1227,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc 0.2.186", +] + [[package]] name = "nix" version = "0.29.0" @@ -1198,7 +1247,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc 0.2.186", "memoffset", ] @@ -1211,7 +1260,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc 0.2.186", ] @@ -1223,7 +1272,7 @@ checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc 0.2.186", "memoffset", ] @@ -1319,6 +1368,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.2" @@ -1354,7 +1409,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" dependencies = [ - "bindgen", + "bindgen 0.72.1", "libspa-sys", "system-deps", ] @@ -1574,6 +1629,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1599,7 +1660,7 @@ dependencies = [ "errno", "libc 0.2.186", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1868,7 +1929,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -2031,6 +2092,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v4l2r" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b609e6cc820e3f95dac66f8ef4efbdf1f39b8d119ec16e6476f797ec95ebaa7" +dependencies = [ + "anyhow", + "bindgen 0.70.1", + "bitflags 2.11.1", + "enumn", + "log", + "nix 0.28.0", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "v4l2r" +version = "0.0.7" +source = "git+https://github.com/Gnurou/v4l2r?rev=7b44138#7b441383125ae583017a1c18b3fc9ec6c88ddbe8" +dependencies = [ + "anyhow", + "bindgen 0.70.1", + "bitflags 2.11.1", + "enumn", + "log", + "nix 0.28.0", + "paste", + "thiserror 1.0.69", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -2056,6 +2148,19 @@ dependencies = [ "vmm-sys-util", ] +[[package]] +name = "vhost" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee90657203a8644e9a0860a0db6a7887d8ef0c7bc09fc22dfa4ae75df65bac86" +dependencies = [ + "bitflags 2.11.1", + "libc 0.2.186", + "uuid", + "vm-memory", + "vmm-sys-util", +] + [[package]] name = "vhost-device-can" version = "0.1.0" @@ -2067,8 +2172,8 @@ dependencies = [ "queues", "socketcan", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2088,8 +2193,8 @@ dependencies = [ "log", "queues", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2107,8 +2212,8 @@ dependencies = [ "libgpiod", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2131,8 +2236,8 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "uuid", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virglrenderer", "virtio-bindings", "virtio-queue", @@ -2150,8 +2255,8 @@ dependencies = [ "libc 0.2.186", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2173,12 +2278,38 @@ dependencies = [ "rand", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", + "virtio-bindings", + "virtio-queue", + "vm-memory", + "vmm-sys-util", +] + +[[package]] +name = "vhost-device-media" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_matches", + "bitflags 2.11.1", + "clap", + "env_logger", + "libc 0.2.186", + "log", + "rstest", + "tempfile", + "thiserror 2.0.18", + "v4l2r 0.0.7 (git+https://github.com/Gnurou/v4l2r?rev=7b44138)", + "vhost 0.16.0", + "vhost-user-backend 0.22.0", "virtio-bindings", + "virtio-media", + "virtio-media-ffmpeg-decoder", "virtio-queue", "vm-memory", "vmm-sys-util", + "zerocopy", ] [[package]] @@ -2194,8 +2325,8 @@ dependencies = [ "rand", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2213,8 +2344,8 @@ dependencies = [ "log", "nix 0.31.2", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2231,8 +2362,8 @@ dependencies = [ "itertools 0.14.0", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2250,8 +2381,8 @@ dependencies = [ "log", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2275,8 +2406,8 @@ dependencies = [ "rusty-fork", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2294,8 +2425,8 @@ dependencies = [ "libc 0.2.186", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2312,8 +2443,8 @@ dependencies = [ "libc 0.2.186", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2335,8 +2466,8 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0", + "vhost-user-backend 0.21.0", "virtio-bindings", "virtio-queue", "virtio-vsock", @@ -2353,7 +2484,22 @@ checksum = "783587813a59c42c36519a6e12bb31eb2d7fa517377428252ba4cc2312584243" dependencies = [ "libc 0.2.186", "log", - "vhost", + "vhost 0.15.0", + "virtio-bindings", + "virtio-queue", + "vm-memory", + "vmm-sys-util", +] + +[[package]] +name = "vhost-user-backend" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5925983d8fb537752ad3e26604c0a17abfa5de77cb6773a096c8a959c9eca0f" +dependencies = [ + "libc 0.2.186", + "log", + "vhost 0.16.0", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2366,7 +2512,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6906bec0a34658c4a81933153a784f9f8d8bcdbe67dcf9e58ea7b67fd1f8ec0b" dependencies = [ - "libc 1.0.0-alpha.1", + "libc 1.0.0-alpha.3", "log", "thiserror 2.0.18", "virglrenderer-sys", @@ -2378,15 +2524,46 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e1cd0732acd1881433c4689bb2d359d64b9a64ddf64ab7231d9db35edbd181a" dependencies = [ - "bindgen", + "bindgen 0.72.1", "pkg-config", ] [[package]] name = "virtio-bindings" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f498a26d5a63be7bbb8bdcd3869c3f286c4c4a17108905276454da0caf8cb" +checksum = "091f1f09cfbf2a78563b562e7a949465cce1aef63b6065645188d995162f8868" + +[[package]] +name = "virtio-media" +version = "0.0.7" +source = "git+https://github.com/chromeos/virtio-media?rev=c6cc93a#c6cc93af5372d6aa37e370f88cfdb9288d27d66a" +dependencies = [ + "anyhow", + "enumn", + "libc 0.2.186", + "log", + "nix 0.28.0", + "thiserror 1.0.69", + "v4l2r 0.0.7 (registry+https://github.com/rust-lang/crates.io-index)", + "zerocopy", +] + +[[package]] +name = "virtio-media-ffmpeg-decoder" +version = "0.0.7" +source = "git+https://github.com/chromeos/virtio-media?rev=c6cc93a#c6cc93af5372d6aa37e370f88cfdb9288d27d66a" +dependencies = [ + "anyhow", + "bindgen 0.70.1", + "enumn", + "libc 0.2.186", + "log", + "nix 0.28.0", + "pkg-config", + "thiserror 1.0.69", + "virtio-media", +] [[package]] name = "virtio-queue" @@ -2597,9 +2774,9 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.61.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] diff --git a/Cargo.toml b/Cargo.toml index 09b115d25..f4b825199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "vhost-device-gpu", "vhost-device-i2c", "vhost-device-input", + "vhost-device-media", "vhost-device-rng", "vhost-device-rtc", "vhost-device-scsi", diff --git a/vhost-device-media/CHANGELOG.md b/vhost-device-media/CHANGELOG.md new file mode 100644 index 000000000..9c2840211 --- /dev/null +++ b/vhost-device-media/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog +## Unreleased + +### Added + +### Changed + +### Fixed + +### Deprecated + +## v0.1.0 + +First release diff --git a/vhost-device-media/Cargo.toml b/vhost-device-media/Cargo.toml new file mode 100644 index 000000000..40b5475dd --- /dev/null +++ b/vhost-device-media/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "vhost-device-media" +version = "0.1.0" +authors = ["Albert Esteve "] +description = "A virtio-media device using the vhost-user protocol." +repository = "https://github.com/rust-vmm/vhost-device" +keywords = ["vhost", "media", "virt", "backend"] +license = "Apache-2.0 OR BSD-3-Clause" +edition = "2021" +readme = "README.md" +categories = ["multimedia::video", "virtualization"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = ["v4l2-proxy"] +v4l2-proxy = ["v4l2r"] +ffmpeg = ["v4l2r", "virtio-media-ffmpeg-decoder"] +simple-capture = ["v4l2r"] +xen = ["vm-memory/xen", "vhost/xen", "vhost-user-backend/xen"] + +[dependencies] +bitflags = "2.6.0" +clap = { version = "4.5.20", features = ["derive"] } +env_logger = "0.11.5" +log = "0.4.22" +libc = "0.2.162" +thiserror = "2.0.3" +anyhow = "1.0.93" +# TODO: switch to workspace = true once workspace pins are updated +vhost = { version = "0.16", features = ["vhost-user-backend"] } +# TODO: switch to workspace = true once workspace pins are updated +vhost-user-backend = { version = "0.22" } +virtio-bindings = { workspace = true } +virtio-queue = { workspace = true } +vm-memory = { workspace = true, features = ["backend-mmap", "backend-atomic"] } +vmm-sys-util = { workspace = true } +virtio-media = { git = "https://github.com/chromeos/virtio-media", rev = "c6cc93a"} +virtio-media-ffmpeg-decoder = { git = "https://github.com/chromeos/virtio-media/", package = "virtio-media-ffmpeg-decoder", rev = "c6cc93a", optional = true} +v4l2r = { git = "https://github.com/Gnurou/v4l2r", rev = "7b44138", optional = true } +zerocopy = { version = "0.8.27", features = ["derive"] } + +[dev-dependencies] +assert_matches = "1.5.0" +rstest = "0.26.1" +tempfile = "3.14.0" +virtio-queue = { workspace = true, features = ["test-utils"] } +vm-memory = { workspace = true, features = ["backend-mmap", "backend-atomic"] } +zerocopy = { version = "0.8.27", features = ["derive"] } + +[lints] +workspace = true diff --git a/vhost-device-media/LICENSE-APACHE b/vhost-device-media/LICENSE-APACHE new file mode 120000 index 000000000..965b606f3 --- /dev/null +++ b/vhost-device-media/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/vhost-device-media/LICENSE-BSD-3-Clause b/vhost-device-media/LICENSE-BSD-3-Clause new file mode 120000 index 000000000..f2b079135 --- /dev/null +++ b/vhost-device-media/LICENSE-BSD-3-Clause @@ -0,0 +1 @@ +../LICENSE-BSD-3-Clause \ No newline at end of file diff --git a/vhost-device-media/README.md b/vhost-device-media/README.md new file mode 100644 index 000000000..9af19aca7 --- /dev/null +++ b/vhost-device-media/README.md @@ -0,0 +1,90 @@ +# vhost-device-media + +A virtio-media device using the vhost-user protocol. + +This crate provides a vhost-user backend for virtio-media devices. It is an implementation of the VIRTIO Media Device specification, which can be found on [virtio-spec v1.4](https://docs.oasis-open.org/virtio/virtio/v1.4/virtio-v1.4.html). The purpose of this device is to provide a standardized way for virtual machines to access media devices on the host. + +The low-level implementation of the virtio-media protocol is provided by the device crate from the [virtio-media](https://github.com/chromeos/virtio-media) repository. + +## Synopsis + +```console +vhost-device-media --socket-path --v4l2-device --backend +``` + +## Description + +`vhost-device-media` implements a vhost-user backend for virtio-media, exposing +one or more V4L2-compatible media devices to a virtual machine guest. The host +side of the device is handled by this daemon, which translates virtio-media +protocol requests into operations on the chosen backend (e.g. a host V4L2 +device, an FFmpeg-based decoder, or a software capture generator). The guest +side requires a virtio-media-capable kernel driver; see the +[virtio-media](https://github.com/chromeos/virtio-media) repository for the +out-of-tree module. + +## Options + +```text + --socket-path + vhost-user Unix domain socket path + + --v4l2-device + Path to the V4L2 media device file (used by v4l2-proxy). Defaults to /dev/video0. + + --backend + Media backend to use. + [possible values: null, simple-capture, v4l2-proxy, ffmpeg-decoder] + Not all values are available in every build; see the Cargo features below. + Defaults to simple-capture when that feature is enabled, null otherwise. + + -h, --help + Print help + + -V, --version + Print version +``` + +## Examples + +Launch the backend on the host machine: + +```shell +host# vhost-device-media --socket-path /tmp/media.sock --v4l2-device /dev/video0 --backend v4l2-proxy +``` + +With QEMU, you can add a `virtio` device that uses the backend's socket with the following flags: + +```text +-chardev socket,id=vmedia,path=/tmp/media.sock \ +-device vhost-user-media-pci,chardev=vmedia,id=media +``` + +## Features + +The crate exposes the following Cargo feature flags. Build with +`--features ` (or multiple comma-separated names) to enable them. + +| Feature | Backend value | Description | +|---------|---------------|-------------| +| `simple-capture` | `simple-capture` | A purely software capture device that generates a test pattern. No hardware required. | +| `v4l2-proxy` | `v4l2-proxy` | Proxy a host V4L2 device (`/dev/videoN`) into the guest as-is. | +| `ffmpeg` | `ffmpeg-decoder` | Software video decoder powered by FFmpeg. | +| *(none)* | `null` | A no-op backend that presents itself as a V4L2 device. | + +**System dependencies** for the `v4l2-proxy` backend: a V4L2-capable host +kernel and access to `/dev/videoN`. + +## Limitations + +This crate is currently under active development. + +- **dmabuf memory sharing**: DMA buffer (dmabuf) support for zero-copy memory sharing between guest and host and through multiple virtio devices using VirtIO shared objects is not yet implemented. Currently, all memory operations use regular memory mappings. +- **Kernel driver availability**: The virtio-media kernel driver is still being upstreamed to the Linux kernel and may not be available in all kernel versions. Check [virtio-media](https://github.com/chromeos/virtio-media) for instructions on how to build the OOT module. + +## License + +This project is licensed under either of + +- [Apache License](http://www.apache.org/licenses/LICENSE-2.0), Version 2.0 +- [BSD-3-Clause License](https://opensource.org/licenses/BSD-3-Clause) diff --git a/vhost-device-media/src/descriptor_chain.rs b/vhost-device-media/src/descriptor_chain.rs new file mode 100644 index 000000000..4cfd6541a --- /dev/null +++ b/vhost-device-media/src/descriptor_chain.rs @@ -0,0 +1,318 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{ + cell::Cell, + cmp::{max, min}, + io, + io::{Read, Write}, + ops::Deref, + rc::Rc, +}; + +use virtio_queue::{desc::split::Descriptor, DescriptorChain, DescriptorChainRwIter}; +use vm_memory::{Address, Bytes, GuestAddress, GuestMemory}; + +#[derive(Clone)] +pub struct DescriptorChainWriter +where + M::Target: GuestMemory, +{ + chain: DescriptorChain, + iter: DescriptorChainRwIter, + current: Option, + offset: u32, + written: u32, + max_written: Rc>, +} + +impl DescriptorChainWriter +where + M::Target: GuestMemory, +{ + pub fn new(chain: DescriptorChain) -> Self { + let mut iter = chain.clone().writable(); + let current = iter.next(); + Self { + chain, + iter, + current, + offset: 0, + written: 0, + max_written: Rc::new(Cell::new(0)), + } + } + + fn add_written(&mut self, written: u32) { + self.written += written; + self.max_written + .set(max(self.max_written.get(), self.written)); + } + + pub fn max_written(&self) -> u32 { + self.max_written.get() + } +} + +impl Write for DescriptorChainWriter +where + M::Target: GuestMemory, +{ + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if let Some(current) = self.current { + let left_in_descriptor: u32 = current.len() - self.offset; + let to_write: u32 = min(left_in_descriptor as usize, buf.len()) as u32; + + let written = self + .chain + .memory() + .write( + &buf[..(to_write as usize)], + GuestAddress( + current + .addr() + .0 + .checked_add(u64::from(self.offset)) + .ok_or(io::Error::other("Guest address overflow"))?, + ), + ) + .map_err(io::Error::other)?; + + self.offset += written as u32; + + if self.offset == current.len() { + self.current = self.iter.next(); + self.offset = 0; + } + + self.add_written(written as u32); + + Ok(written) + } else { + Ok(0) + } + } + + fn flush(&mut self) -> std::io::Result<()> { + // no-op: we're writing directly to guest memory + Ok(()) + } +} + +/// A `Read` implementation that reads from the memory indicated by a virtio +/// descriptor chain. +pub struct DescriptorChainReader +where + M::Target: GuestMemory, +{ + chain: DescriptorChain, + iter: DescriptorChainRwIter, + current: Option, + offset: u32, +} + +impl DescriptorChainReader +where + M::Target: GuestMemory, +{ + pub fn new(chain: DescriptorChain) -> Self { + let mut iter = chain.clone().readable(); + let current = iter.next(); + + Self { + chain, + iter, + current, + offset: 0, + } + } +} + +impl Read for DescriptorChainReader +where + M::Target: GuestMemory, +{ + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if let Some(current) = self.current { + let left_in_descriptor = current.len() - self.offset; + let to_read: u32 = min(left_in_descriptor, buf.len() as u32); + + let addr = current + .addr() + .checked_add(u64::from(self.offset)) + .ok_or_else(|| io::Error::other("guest address overflow"))?; + let read = self + .chain + .memory() + .read(&mut buf[..(to_read as usize)], addr) + .map_err(io::Error::other)?; + + self.offset += read as u32; + + if self.offset == current.len() { + self.current = self.iter.next(); + self.offset = 0; + } + + Ok(read) + } else { + Ok(0) + } + } +} + +#[cfg(test)] +mod tests { + use rstest::*; + use virtio_bindings::bindings::virtio_ring::VRING_DESC_F_WRITE; + use virtio_queue::{desc::RawDescriptor, mock::MockSplitQueue}; + use vm_memory::{Bytes, GuestAddress, GuestMemoryMmap}; + + use super::*; + + #[rstest] + #[case::small_payload(&[0xAAu8, 0xBB], 0x1000)] + #[case::medium_payload(&[0xAAu8, 0xBB, 0xCC, 0xDD], 0x1000)] + #[case::large_payload(&[0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88], 0x1000)] + #[case::single_byte(&[0xFFu8], 0x2000)] + #[case::all_zeros(&[0u8; 16], 0x3000)] + fn test_descriptor_chain_reader_reads_payload(#[case] payload: &[u8], #[case] addr: u64) { + let mem: GuestMemoryMmap = + GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(); + mem.write(payload, GuestAddress(addr)).unwrap(); + + // Readable descriptor. + let v = vec![RawDescriptor::from(Descriptor::new( + addr, + payload.len() as u32, + 0, + 0, + ))]; + let queue = MockSplitQueue::new(&mem, 16); + let chain = queue.build_desc_chain(&v).unwrap(); + + let mut reader = DescriptorChainReader::new(chain); + let mut out = vec![0u8; payload.len()]; + let n = reader.read(&mut out).unwrap(); + assert_eq!(n, payload.len()); + assert_eq!(out, payload); + } + + #[rstest] + #[case(&[1, 2, 3, 4], 4, 0x2000)] + #[case(&[0xFF, 0xFE, 0xFD], 3, 0x2100)] + #[case(&[0u8; 8], 8, 0x2200)] + #[case(&[1], 1, 0x2300)] + fn test_descriptor_chain_writer_writes_payload_and_tracks_max_written( + #[case] data: &[u8], + #[case] expected_written: usize, + #[case] addr: u64, + ) { + let mem: GuestMemoryMmap = + GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(); + + // Writable descriptor with enough space. + let v = vec![RawDescriptor::from(Descriptor::new( + addr, + (data.len() + 4) as u32, // Extra space to ensure we don't hit the limit + VRING_DESC_F_WRITE as u16, + 0, + ))]; + let queue = MockSplitQueue::new(&mem, 16); + let chain = queue.build_desc_chain(&v).unwrap(); + + let mut writer = DescriptorChainWriter::new(chain); + let n = writer.write(data).unwrap(); + assert_eq!(n, expected_written); + assert_eq!(writer.max_written(), expected_written as u32); + + let mut out = vec![0u8; data.len()]; + mem.read(&mut out, GuestAddress(addr)).unwrap(); + assert_eq!(out, data); + } + + #[rstest] + #[case(&[9, 9, 9], 0x3000)] + #[case(&[1, 2, 3, 4, 5], 0x4000)] + #[case(&[0xFF], 0x5000)] + fn test_writer_returns_zero_without_writable_descriptor( + #[case] data: &[u8], + #[case] addr: u64, + ) { + let mem: GuestMemoryMmap = + GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(); + + // Readable only descriptor; writable iterator should be empty. + let v = vec![RawDescriptor::from(Descriptor::new(addr, 8, 0, 0))]; + let queue = MockSplitQueue::new(&mem, 16); + let chain = queue.build_desc_chain(&v).unwrap(); + + let mut writer = DescriptorChainWriter::new(chain); + let n = writer.write(data).unwrap(); + assert_eq!(n, 0); + assert_eq!(writer.max_written(), 0); + } + + #[test] + fn test_writer_flush_is_noop() { + let mem: GuestMemoryMmap = + GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(); + + let v = vec![RawDescriptor::from(Descriptor::new( + 0x1000, + 64, + VRING_DESC_F_WRITE as u16, + 0, + ))]; + let queue = MockSplitQueue::new(&mem, 16); + let chain = queue.build_desc_chain(&v).unwrap(); + + let mut writer = DescriptorChainWriter::new(chain); + writer.flush().unwrap(); + } + + #[test] + fn test_reader_exhausted_returns_zero() { + let mem: GuestMemoryMmap = + GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(); + mem.write(&[1, 2, 3, 4], GuestAddress(0x1000)).unwrap(); + + let v = vec![RawDescriptor::from(Descriptor::new(0x1000, 4, 0, 0))]; + let queue = MockSplitQueue::new(&mem, 16); + let chain = queue.build_desc_chain(&v).unwrap(); + + let mut reader = DescriptorChainReader::new(chain); + let mut buf = [0u8; 4]; + assert_eq!(reader.read(&mut buf).unwrap(), 4); + assert_eq!(reader.read(&mut buf).unwrap(), 0); + } + + #[rstest] + #[case(1, 1)] + #[case(4, 4)] + #[case(8, 8)] + #[case(16, 16)] + #[case(32, 32)] + fn test_writer_partial_writes(#[case] descriptor_size: u32, #[case] write_size: usize) { + let mem: GuestMemoryMmap = + GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(); + + // Writable descriptor with specific size. + let v = vec![RawDescriptor::from(Descriptor::new( + 0x6000, + descriptor_size, + VRING_DESC_F_WRITE as u16, + 0, + ))]; + let queue = MockSplitQueue::new(&mem, 16); + let chain = queue.build_desc_chain(&v).unwrap(); + + let mut writer = DescriptorChainWriter::new(chain); + let data = vec![0xAAu8; write_size]; + let expected_written = std::cmp::min(write_size, descriptor_size as usize); + let n = writer.write(&data).unwrap(); + assert_eq!(n, expected_written); + assert_eq!(writer.max_written(), expected_written as u32); + } +} diff --git a/vhost-device-media/src/lib.rs b/vhost-device-media/src/lib.rs new file mode 100644 index 000000000..ef86c906e --- /dev/null +++ b/vhost-device-media/src/lib.rs @@ -0,0 +1,441 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +mod descriptor_chain; +mod media_allocator; +mod null_backend; +mod vhu_adapters; +pub mod vhu_media; +mod vhu_media_thread; + +use std::{path::PathBuf, sync::Arc}; + +use ::virtio_media::protocol::VirtioMediaDeviceConfig; +use log::debug; +use thiserror::Error as ThisError; +use vhost_user_backend::VhostUserDaemon; +use vhu_media::VuMediaBackend; +pub use vhu_media::{BackendType, VuMediaError}; +use vm_memory::{GuestMemoryAtomic, GuestMemoryMmap}; + +pub type Result = std::result::Result; + +const VIRTIO_V4L2_CARD_NAME_LEN: usize = 32; + +/// V4L2 device types as defined by the V4L2 framework. +/// +/// These correspond to the VFL_TYPE_* constants in the Linux kernel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +enum V4l2DeviceType { + Video = 0, // VFL_TYPE_VIDEO + Vbi = 1, // VFL_TYPE_VBI + Radio = 2, // VFL_TYPE_RADIO + Sdr = 3, // VFL_TYPE_SDR + Touch = 5, // VFL_TYPE_TOUCH +} + +impl V4l2DeviceType { + #[cfg(feature = "v4l2-proxy")] + fn from_path(device_path: &std::path::Path) -> Self { + // Resolve symlinks (e.g., /dev/v4l/by-id/...) to the actual device node + let actual_path = + std::fs::canonicalize(device_path).unwrap_or_else(|_| device_path.to_path_buf()); + + let filename = actual_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + match filename { + f if f.starts_with("video") => Self::Video, + f if f.starts_with("vbi") => Self::Vbi, + f if f.starts_with("radio") => Self::Radio, + f if f.starts_with("swradio") || f.starts_with("sdr") => Self::Sdr, + f if f.starts_with("touch") => Self::Touch, + _ => Self::Video, // Default to VIDEO for unknown paths + } + } +} + +#[derive(Debug, ThisError)] +/// Errors related to vhost-device-media daemon. +pub enum Error { + #[error("Could not create backend: {0}")] + CouldNotCreateBackend(vhu_media::VuMediaError), + #[error("Could not open device `{0}`: {1}")] + CouldNotOpenDevice(PathBuf, String), + #[error("Could not create daemon: {0}")] + CouldNotCreateDaemon(vhost_user_backend::Error), + #[error("Fatal error: {0}")] + ServeFailed(vhost_user_backend::Error), +} + +#[derive(Debug, Eq, PartialEq)] +pub struct VuMediaConfig { + pub socket_path: PathBuf, + pub v4l2_device: PathBuf, + pub backend: BackendType, +} + +#[cfg(feature = "simple-capture")] +pub fn create_simple_capture_device_config() -> VirtioMediaDeviceConfig { + use v4l2r::ioctl::Capabilities; + let mut card = [0u8; VIRTIO_V4L2_CARD_NAME_LEN]; + let card_name = "simple_device"; + card[0..card_name.len()].copy_from_slice(card_name.as_bytes()); + VirtioMediaDeviceConfig { + device_caps: (Capabilities::VIDEO_CAPTURE | Capabilities::STREAMING).bits(), + device_type: V4l2DeviceType::Video as u32, + card, + } +} + +#[cfg(feature = "v4l2-proxy")] +pub fn create_v4l2_proxy_device_config(device_path: &PathBuf) -> Result { + use virtio_media::v4l2r::ioctl::Capabilities; + + let device = virtio_media::v4l2r::device::Device::open( + device_path.as_ref(), + virtio_media::v4l2r::device::DeviceConfig::new().non_blocking_dqbuf(), + ) + .map_err(|e| Error::CouldNotOpenDevice(device_path.clone(), e.to_string()))?; + let mut device_caps = device.caps().device_caps(); + + // We are only exposing one device worth of capabilities. + device_caps.remove(Capabilities::DEVICE_CAPS); + + // Read-write is not supported by design. + device_caps.remove(Capabilities::READWRITE); + + let mut config = VirtioMediaDeviceConfig { + device_caps: device_caps.bits(), + device_type: V4l2DeviceType::from_path(device_path.as_path()) as u32, + card: Default::default(), + }; + let card = &device.caps().card; + let name_slice = &card.as_bytes()[0..std::cmp::min(card.len(), config.card.len())]; + config.card.as_mut_slice()[0..name_slice.len()].copy_from_slice(name_slice); + + Ok(config) +} + +#[cfg(feature = "ffmpeg")] +pub fn create_ffmpeg_decoder_config() -> VirtioMediaDeviceConfig { + use v4l2r::ioctl::Capabilities; + let mut card = [0u8; VIRTIO_V4L2_CARD_NAME_LEN]; + let card_name = "ffmpeg_decoder"; + card[0..card_name.len()].copy_from_slice(card_name.as_bytes()); + VirtioMediaDeviceConfig { + device_caps: (Capabilities::VIDEO_M2M_MPLANE + | Capabilities::EXT_PIX_FORMAT + | Capabilities::STREAMING + | Capabilities::DEVICE_CAPS) + .bits(), + device_type: V4l2DeviceType::Video as u32, + card, + } +} + +#[cfg(feature = "simple-capture")] +fn serve_simple_capture(media_config: &VuMediaConfig) -> Result<()> { + let vu_media_backend = Arc::new( + VuMediaBackend::new( + media_config.v4l2_device.as_path(), + create_simple_capture_device_config(), + move |event_queue, _, host_mapper| { + Ok(virtio_media::devices::SimpleCaptureDevice::new( + event_queue, + host_mapper, + )) + }, + ) + .map_err(Error::CouldNotCreateBackend)?, + ); + let mut daemon = VhostUserDaemon::new( + "vhost-device-media".to_owned(), + vu_media_backend.clone(), + GuestMemoryAtomic::new(GuestMemoryMmap::new()), + ) + .map_err(Error::CouldNotCreateDaemon)?; + + vu_media_backend.set_thread_workers(&mut daemon.get_epoll_handlers()); + + daemon + .serve(&media_config.socket_path) + .map_err(Error::ServeFailed)?; + + Ok(()) +} + +#[cfg(feature = "v4l2-proxy")] +fn serve_v4l2_proxy_daemon(media_config: &VuMediaConfig) -> Result<()> { + let path = media_config.v4l2_device.clone(); + let vu_media_backend = Arc::new( + VuMediaBackend::new( + media_config.v4l2_device.as_path(), + create_v4l2_proxy_device_config(&path)?, + move |event_queue, guest_mapper, host_mapper| { + Ok(virtio_media::devices::V4l2ProxyDevice::new( + path.clone(), + event_queue, + guest_mapper, + host_mapper, + )) + }, + ) + .map_err(Error::CouldNotCreateBackend)?, + ); + let mut daemon = VhostUserDaemon::new( + "vhost-device-media".to_owned(), + vu_media_backend.clone(), + GuestMemoryAtomic::new(GuestMemoryMmap::new()), + ) + .map_err(Error::CouldNotCreateDaemon)?; + + vu_media_backend.set_thread_workers(&mut daemon.get_epoll_handlers()); + daemon + .serve(&media_config.socket_path) + .map_err(Error::ServeFailed)?; + + Ok(()) +} + +fn serve_null(media_config: &VuMediaConfig) -> Result<()> { + let mut card = [0u8; VIRTIO_V4L2_CARD_NAME_LEN]; + card[..4].copy_from_slice(b"null"); + let vu_media_backend = Arc::new( + VuMediaBackend::new( + media_config.v4l2_device.as_path(), + VirtioMediaDeviceConfig { + device_caps: 0, + device_type: V4l2DeviceType::Video as u32, + card, + }, + |_, _, _| Ok(null_backend::NullMediaDevice), + ) + .map_err(Error::CouldNotCreateBackend)?, + ); + let mut daemon = VhostUserDaemon::new( + "vhost-device-media".to_owned(), + vu_media_backend.clone(), + GuestMemoryAtomic::new(GuestMemoryMmap::new()), + ) + .map_err(Error::CouldNotCreateDaemon)?; + + vu_media_backend.set_thread_workers(&mut daemon.get_epoll_handlers()); + daemon + .serve(&media_config.socket_path) + .map_err(Error::ServeFailed)?; + + Ok(()) +} + +#[cfg(feature = "ffmpeg")] +fn serve_ffmpeg_decoder(media_config: &VuMediaConfig) -> Result<()> { + let vu_media_backend = Arc::new( + VuMediaBackend::new( + media_config.v4l2_device.as_path(), + create_ffmpeg_decoder_config(), + move |event_queue, _, host_mapper| { + Ok(virtio_media::devices::video_decoder::VideoDecoder::new( + virtio_media_ffmpeg_decoder::FfmpegDecoder::new(), + event_queue, + host_mapper, + )) + }, + ) + .map_err(Error::CouldNotCreateBackend)?, + ); + + let mut daemon = VhostUserDaemon::new( + "vhost-device-media".to_owned(), + vu_media_backend.clone(), + GuestMemoryAtomic::new(GuestMemoryMmap::new()), + ) + .map_err(Error::CouldNotCreateDaemon)?; + + vu_media_backend.set_thread_workers(&mut daemon.get_epoll_handlers()); + daemon + .serve(&media_config.socket_path) + .map_err(Error::ServeFailed)?; + + Ok(()) +} + +/// Starts the vhost-device-media daemon. +/// +/// This function does not return under normal operation, it loops indefinitely, +/// re-spawning the chosen backend after each guest disconnect. It only returns +/// `Err` if the backend itself fails to start. +pub fn start_backend(media_config: VuMediaConfig) -> Result<()> { + loop { + debug!("Starting backend"); + match media_config.backend { + BackendType::Null => serve_null(&media_config), + #[cfg(feature = "simple-capture")] + BackendType::SimpleCapture => serve_simple_capture(&media_config), + #[cfg(feature = "v4l2-proxy")] + BackendType::V4l2Proxy => serve_v4l2_proxy_daemon(&media_config), + #[cfg(feature = "ffmpeg")] + BackendType::FfmpegDecoder => serve_ffmpeg_decoder(&media_config), + }?; + debug!("Finishing backend"); + } +} + +#[cfg(test)] +#[cfg(any(feature = "simple-capture", feature = "v4l2-proxy", feature = "ffmpeg"))] +mod tests { + #[cfg(feature = "v4l2-proxy")] + use std::path::Path; + + use rstest::*; + #[cfg(feature = "v4l2-proxy")] + use tempfile::tempdir; + #[cfg(any(feature = "simple-capture", feature = "ffmpeg"))] + use virtio_media::protocol::VirtioMediaDeviceConfig; + + use super::*; + + #[cfg(feature = "v4l2-proxy")] + #[rstest] + #[case("/dev/video0", V4l2DeviceType::Video)] + #[case("/dev/video1", V4l2DeviceType::Video)] + #[case("/dev/video99", V4l2DeviceType::Video)] + #[case("/dev/vbi0", V4l2DeviceType::Vbi)] + #[case("/dev/vbi1", V4l2DeviceType::Vbi)] + #[case("/dev/radio0", V4l2DeviceType::Radio)] + #[case("/dev/radio1", V4l2DeviceType::Radio)] + #[case("/dev/swradio0", V4l2DeviceType::Sdr)] + #[case("/dev/sdr0", V4l2DeviceType::Sdr)] + #[case("/dev/sdr1", V4l2DeviceType::Sdr)] + #[case("/dev/touch0", V4l2DeviceType::Touch)] + #[case("/dev/touch1", V4l2DeviceType::Touch)] + fn test_v4l2_device_type_from_path( + #[case] device_path: &str, + #[case] expected_type: V4l2DeviceType, + ) { + assert_eq!( + V4l2DeviceType::from_path(Path::new(device_path)) as u32, + expected_type as u32 + ); + } + + #[cfg(feature = "v4l2-proxy")] + #[rstest] + #[case("/dev/unknown0")] + #[case("/dev/other")] + fn test_v4l2_device_type_from_path_unknown_defaults_to_video(#[case] device_path: &str) { + // Unknown device types should default to Video + assert_eq!( + V4l2DeviceType::from_path(Path::new(device_path)) as u32, + V4l2DeviceType::Video as u32 + ); + } + + #[cfg(feature = "v4l2-proxy")] + #[rstest] + #[case("/")] + #[case("/dev/")] + fn test_v4l2_device_type_from_path_no_filename(#[case] device_path: &str) { + // Paths without a filename should default to Video + assert_eq!( + V4l2DeviceType::from_path(Path::new(device_path)) as u32, + V4l2DeviceType::Video as u32 + ); + } + + #[cfg(feature = "v4l2-proxy")] + #[test] + fn test_v4l2_device_type_from_path_symlink() { + // Test symlink resolution by creating a temporary symlink + let temp_dir = tempdir().unwrap(); + let target = temp_dir.path().join("vbi0"); + let symlink = temp_dir.path().join("link-to-vbi0"); + + // Create a dummy file to symlink to + std::fs::File::create(&target).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &symlink).unwrap(); + + // On Unix, the symlink should resolve to the target (vbi0) -> Vbi + // On non-Unix, canonicalize fails, so it falls back to Video + let expected = if cfg!(unix) { + V4l2DeviceType::Vbi as u32 + } else { + V4l2DeviceType::Video as u32 + }; + assert_eq!(V4l2DeviceType::from_path(&symlink) as u32, expected); + } + + #[cfg(feature = "simple-capture")] + #[rstest] + #[case(create_simple_capture_device_config(), 13, b"simple_device")] + fn test_simple_capture_device_config_shape( + #[case] cfg: VirtioMediaDeviceConfig, + #[case] card_name_len: usize, + #[case] expected_card_prefix: &[u8], + ) { + assert_eq!(cfg.device_type, 0); + assert!(cfg.device_caps != 0); + assert_eq!(cfg.card.len(), VIRTIO_V4L2_CARD_NAME_LEN); + assert_eq!(&cfg.card[..card_name_len], expected_card_prefix); + } + + #[cfg(feature = "ffmpeg")] + #[rstest] + #[case(create_ffmpeg_decoder_config(), 14, b"ffmpeg_decoder")] + fn test_ffmpeg_decoder_config_shape( + #[case] cfg: VirtioMediaDeviceConfig, + #[case] card_name_len: usize, + #[case] expected_card_prefix: &[u8], + ) { + assert_eq!(cfg.device_type, 0); + assert!(cfg.device_caps != 0); + assert_eq!(cfg.card.len(), VIRTIO_V4L2_CARD_NAME_LEN); + assert_eq!(&cfg.card[..card_name_len], expected_card_prefix); + } +} + +/// Smoke tests for the null backend and start_backend dispatch — no features required. +#[cfg(test)] +mod null_backend_tests { + use std::{path::PathBuf, thread, time::Duration}; + + use tempfile::tempdir; + + use super::*; + + /// Verify that start_backend with the null backend binds the socket and + /// keeps running without panicking. + #[test] + fn test_start_backend_null_binds_socket() { + let dir = tempdir().unwrap(); + let socket_path = dir.path().join("vhost-media-null.sock"); + let socket_path_check = socket_path.clone(); + + let config = VuMediaConfig { + socket_path, + v4l2_device: PathBuf::from("/dev/null"), + backend: BackendType::Null, + }; + + // start_backend loops; run it in a background thread. + let _handle = thread::spawn(move || start_backend(config)); + + // Poll until the daemon creates the socket file (up to 2 s). + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if socket_path_check.exists() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for null backend to bind socket" + ); + thread::sleep(Duration::from_millis(10)); + } + } +} diff --git a/vhost-device-media/src/main.rs b/vhost-device-media/src/main.rs new file mode 100644 index 000000000..c32668273 --- /dev/null +++ b/vhost-device-media/src/main.rs @@ -0,0 +1,188 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +extern crate vhost_device_media; + +use std::path::PathBuf; + +use clap::Parser; +use vhost_device_media::{start_backend, BackendType, Error, VuMediaConfig}; + +#[derive(Clone, Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct MediaArgs { + /// Unix socket to which a hypervisor connects to and sets up the control + /// path with the device. + #[clap(short, long)] + socket_path: PathBuf, + + /// Path to the V4L2 media device file. Defaults to /dev/video0. + #[clap(short = 'd', long, default_value = "/dev/video0")] + v4l2_device: PathBuf, + + /// Media backend to be used. + #[clap(short, long, default_value = "simple-capture")] + #[clap(value_enum)] + backend: BackendType, +} + +impl From for VuMediaConfig { + fn from(args: MediaArgs) -> Self { + Self { + socket_path: args.socket_path, + v4l2_device: args.v4l2_device, + backend: args.backend, + } + } +} + +fn main() -> std::result::Result<(), Error> { + env_logger::init(); + + start_backend(VuMediaConfig::from(MediaArgs::parse())) +} + +#[cfg(test)] +mod tests { + #[cfg(any(feature = "simple-capture", feature = "v4l2-proxy", feature = "ffmpeg"))] + use rstest::*; + + use super::*; + + #[cfg(any(feature = "simple-capture", feature = "v4l2-proxy", feature = "ffmpeg"))] + #[rstest] + #[cfg_attr( + feature = "simple-capture", + case::simple_capture("simple-capture", BackendType::SimpleCapture) + )] + #[cfg_attr( + feature = "v4l2-proxy", + case::v4l2_proxy("v4l2-proxy", BackendType::V4l2Proxy) + )] + #[cfg_attr( + feature = "ffmpeg", + case::ffmpeg_decoder("ffmpeg-decoder", BackendType::FfmpegDecoder) + )] + fn test_cli_backend_arg(#[case] backend_name: &str, #[case] backend: BackendType) { + let args = MediaArgs::try_parse_from([ + "vhost-device-media", + "--socket-path", + "/tmp/vmedia.sock", + "--backend", + backend_name, + ]) + .unwrap(); + + assert_eq!(args.backend, backend); + } + + #[cfg(any(feature = "simple-capture", feature = "v4l2-proxy", feature = "ffmpeg"))] + #[rstest] + #[cfg_attr( + feature = "simple-capture", + case::simple_capture( + "simple-capture", + BackendType::SimpleCapture, + "/tmp/vmedia.sock", + "/dev/video7" + ) + )] + #[cfg_attr( + feature = "v4l2-proxy", + case::v4l2_proxy_alt( + "v4l2-proxy", + BackendType::V4l2Proxy, + "/tmp/other.sock", + "/dev/video0" + ) + )] + #[cfg_attr( + feature = "ffmpeg", + case::ffmpeg_decoder( + "ffmpeg-decoder", + BackendType::FfmpegDecoder, + "/tmp/ffmpeg.sock", + "/dev/video3" + ) + )] + fn test_media_args_parse_explicit_values( + #[case] backend_name: &str, + #[case] expected_backend: BackendType, + #[case] socket: &str, + #[case] device: &str, + ) { + let args = MediaArgs::try_parse_from([ + "vhost-device-media", + "--socket-path", + socket, + "--v4l2-device", + device, + "--backend", + backend_name, + ]) + .unwrap(); + + assert_eq!(args.socket_path, PathBuf::from(socket)); + assert_eq!(args.v4l2_device, PathBuf::from(device)); + assert_eq!(args.backend, expected_backend); + } + + #[test] + fn test_media_args_parse_defaults() { + let res = MediaArgs::try_parse_from([ + "vhost-device-media", + "--socket-path", + "/tmp/vmedia-default.sock", + ]); + + #[cfg(feature = "simple-capture")] + { + let args = res.unwrap(); + assert_eq!(args.socket_path, PathBuf::from("/tmp/vmedia-default.sock")); + assert_eq!(args.v4l2_device, PathBuf::from("/dev/video0")); + // Default CLI backend is simple-capture. + assert_eq!(args.backend, BackendType::SimpleCapture); + } + + #[cfg(not(feature = "simple-capture"))] + { + // If simple-capture is compiled out, the hardcoded default backend + // becomes invalid and clap should reject parsing. + res.unwrap_err(); + } + } + + #[test] + fn test_media_args_parse_missing_socket_fails() { + let res = MediaArgs::try_parse_from(["vhost-device-media"]); + res.unwrap_err(); + } + + #[test] + fn test_media_args_parse_invalid_backend_fails() { + let res = MediaArgs::try_parse_from([ + "vhost-device-media", + "--socket-path", + "/tmp/vmedia-invalid.sock", + "--backend", + "not-a-backend", + ]); + res.unwrap_err(); + } + + #[test] + #[cfg(feature = "simple-capture")] + fn test_from_media_args_for_vu_media_config() { + let args = MediaArgs { + socket_path: PathBuf::from("/tmp/a.sock"), + v4l2_device: PathBuf::from("/dev/video99"), + backend: BackendType::SimpleCapture, + }; + + let config = VuMediaConfig::from(args.clone()); + assert_eq!(config.socket_path, args.socket_path); + assert_eq!(config.v4l2_device, args.v4l2_device); + assert_eq!(config.backend, args.backend); + } +} diff --git a/vhost-device-media/src/media_allocator.rs b/vhost-device-media/src/media_allocator.rs new file mode 100644 index 000000000..ef89694c7 --- /dev/null +++ b/vhost-device-media/src/media_allocator.rs @@ -0,0 +1,541 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{ + cmp, + collections::{BTreeSet, HashMap}, + ops::Bound, +}; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct AddressRange { + pub start: u64, + pub end: u64, +} + +impl AddressRange { + pub const fn from_range(start: u64, end: u64) -> Self { + Self { start, end } + } + + /// Returns an empty range. + pub const fn empty() -> Self { + AddressRange { start: 1, end: 0 } + } + + /// Returns `true` if this range is empty (contains no addresses). + pub fn is_empty(&self) -> bool { + self.end < self.start + } + + pub fn non_overlapping_ranges(&self, other: AddressRange) -> (AddressRange, AddressRange) { + let before = if self.start >= other.start { + Self::empty() + } else { + let start = cmp::min(self.start, other.start); + + // We know that self.start != other.start, so the maximum of the two cannot be + // 0, so it is safe to subtract 1. + let end = cmp::max(self.start, other.start) - 1; + + // For non-overlapping ranges, don't allow end to extend past self.end. + let end = cmp::min(end, self.end); + + AddressRange { start, end } + }; + + let after = if self.end <= other.end { + Self::empty() + } else { + // We know that self.end != other.end, so the minimum of the two cannot be + // `u64::MAX`, so it is safe to add 1. + let start = cmp::min(self.end, other.end) + 1; + + // For non-overlapping ranges, don't allow start to extend before self.start. + let start = cmp::max(start, self.start); + + let end = cmp::max(self.end, other.end); + + AddressRange { start, end } + }; + + (before, after) + } + + pub fn overlaps(&self, other: AddressRange) -> bool { + !self.intersect(other).is_empty() + } + + pub fn intersect(&self, other: AddressRange) -> AddressRange { + let start = cmp::max(self.start, other.start); + let end = cmp::min(self.end, other.end); + AddressRange { start, end } + } + + pub fn len(&self) -> Option { + // Treat any range we consider "empty" (end < start) as having 0 length. + if self.is_empty() { + Some(0) + } else { + (self.end - self.start).checked_add(1) + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct MediaAllocator { + pools: Vec, + min_align: u64, + /// The region that is allocated. + allocs: HashMap, + /// The region that is not allocated yet. + regions: BTreeSet, +} + +impl MediaAllocator { + pub fn new(pool: AddressRange, min_align: Option) -> Result { + Self::new_from_list(vec![pool], min_align) + } + + pub fn new_from_list(pools: T, min_align: Option) -> Result + where + T: IntoIterator, + { + let pools: Vec = pools.into_iter().filter(|p| !p.is_empty()).collect(); + + let min_align = min_align.unwrap_or(4); + if !min_align.is_power_of_two() || min_align == 0 { + return Err(libc::EBADR); + } + + let mut regions = BTreeSet::new(); + for r in pools.iter() { + regions.insert(*r); + } + Ok(MediaAllocator { + pools, + min_align, + allocs: HashMap::new(), + regions, + }) + } + + fn internal_allocate_from_slot( + &mut self, + slot: AddressRange, + range: AddressRange, + id: u64, + ) -> Result { + let slot_was_present = self.regions.remove(&slot); + assert!(slot_was_present); + + let (before, after) = slot.non_overlapping_ranges(range); + + if !before.is_empty() { + self.regions.insert(before); + } + if !after.is_empty() { + self.regions.insert(after); + } + + self.allocs.insert(id, range); + Ok(range.start) + } + + pub fn allocate(&mut self, size: u64, id: u64) -> Result { + if self.allocs.contains_key(&id) { + return Err(libc::EADDRINUSE); + } + if size == 0 { + return Err(libc::EINVAL); + } + // finds first region matching alignment and size. + let region = self + .regions + .iter() + .find(|range| { + match range.start % self.min_align { + 0 => range.start.checked_add(size - 1), + r => range.start.checked_add(size - 1 + self.min_align - r), + } + .is_some_and(|end| end <= range.end) + }) + .cloned(); + + match region { + Some(slot) => { + let start = match slot.start % self.min_align { + 0 => slot.start, + r => slot.start + self.min_align - r, + }; + let end = start + size - 1; + let range = AddressRange { start, end }; + + self.internal_allocate_from_slot(slot, range, id) + } + None => Err(libc::EFAULT), + } + } + + fn insert_at(&mut self, mut slot: AddressRange) -> Result<()> { + if slot.is_empty() { + return Err(libc::EINVAL); + } + + // Find the region with the highest starting address that is at most + // |slot.start|. Check if it overlaps with |slot|, or if it is adjacent to + // (and thus can be coalesced with) |slot|. + let mut smaller_merge = None; + if let Some(smaller) = self + .regions + .range((Bound::Unbounded, Bound::Included(slot))) + .max() + { + // If there is overflow, then |smaller| covers up through u64::MAX + let next_addr = smaller.end.checked_add(1).ok_or(libc::EBADR)?; + match next_addr.cmp(&slot.start) { + cmp::Ordering::Less => (), + cmp::Ordering::Equal => smaller_merge = Some(*smaller), + cmp::Ordering::Greater => return Err(libc::EBADR), + } + } + + let mut larger_merge = None; + if let Some(larger) = self + .regions + .range((Bound::Excluded(slot), Bound::Unbounded)) + .min() + { + // If there is underflow, then |larger| covers down through 0 + let prev_addr = larger.start.checked_sub(1).ok_or(libc::EBADR)?; + match slot.end.cmp(&prev_addr) { + cmp::Ordering::Less => (), + cmp::Ordering::Equal => larger_merge = Some(*larger), + cmp::Ordering::Greater => return Err(libc::EBADR), + } + } + + if let Some(smaller) = smaller_merge { + self.regions.remove(&smaller); + slot.start = smaller.start; + } + if let Some(larger) = larger_merge { + self.regions.remove(&larger); + slot.end = larger.end; + } + self.regions.insert(slot); + + Ok(()) + } + + pub fn release(&mut self, id: u64) -> Result { + if let Some(range) = self.allocs.remove(&id) { + self.insert_at(range)?; + Ok(range) + } else { + Err(libc::EINVAL) + } + } + + pub fn release_containing(&mut self, value: u64) -> Result { + if let Some(id) = self.find_overlapping(AddressRange { + start: value, + end: value, + }) { + self.release(id) + } else { + Err(libc::EFAULT) + } + } + + fn find_overlapping(&self, range: AddressRange) -> Option { + if range.is_empty() { + return None; + } + + self.allocs + .iter() + .find(|(_, &alloc_range)| alloc_range.overlaps(range)) + .map(|(&alloc, _)| alloc) + } +} + +#[cfg(test)] +mod tests { + use rstest::*; + + use super::*; + + #[rstest] + #[case(0, 1023, None, 4)] + #[case(0, 2047, Some(8), 8)] + #[case(100, 999, Some(16), 16)] + #[case(0, 4095, Some(4096), 4096)] + fn test_allocator_new( + #[case] start: u64, + #[case] end: u64, + #[case] min_align: Option, + #[case] expected_align: u64, + ) { + let pool = AddressRange::from_range(start, end); + let allocator = MediaAllocator::new(pool, min_align).unwrap(); + assert_eq!(allocator.pools, vec![pool]); + assert_eq!(allocator.min_align, expected_align); + assert_eq!(allocator.allocs, HashMap::new()); + let mut regions = BTreeSet::new(); + regions.insert(pool); + assert_eq!(allocator.regions, regions); + } + + #[rstest] + #[case( + 256, + 1, + 0, + AddressRange::from_range(0, 255), + AddressRange::from_range(256, 1023) + )] + #[case( + 128, + 2, + 0, + AddressRange::from_range(0, 127), + AddressRange::from_range(128, 1023) + )] + #[case( + 512, + 3, + 0, + AddressRange::from_range(0, 511), + AddressRange::from_range(512, 1023) + )] + #[case( + 64, + 4, + 0, + AddressRange::from_range(0, 63), + AddressRange::from_range(64, 1023) + )] + fn test_allocator_allocate_and_release( + #[case] size: u64, + #[case] id: u64, + #[case] expected_offset: u64, + #[case] expected_alloc_range: AddressRange, + #[case] expected_free_range: AddressRange, + ) { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + + // Allocate a region + let offset = allocator.allocate(size, id).unwrap(); + assert_eq!(offset, expected_offset); + assert_eq!(allocator.allocs.get(&id), Some(&expected_alloc_range)); + assert_eq!(allocator.regions.iter().next(), Some(&expected_free_range)); + + // Release the region + let released_range = allocator.release(id).unwrap(); + assert_eq!(released_range, expected_alloc_range); + assert!(allocator.allocs.is_empty()); + assert_eq!(allocator.regions.iter().next(), Some(&pool)); + } + + #[rstest] + #[case(2048, 1, libc::EFAULT)] + #[case(4096, 2, libc::EFAULT)] + #[case(1025, 3, libc::EFAULT)] // One byte larger than the pool + fn test_allocator_allocate_too_large( + #[case] size: u64, + #[case] id: u64, + #[case] expected_error: i32, + ) { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + assert_eq!(allocator.allocate(size, id), Err(expected_error)); + } + + #[rstest] + #[case(128, 1, 64, 2)] + #[case(256, 5, 128, 6)] + #[case(512, 10, 256, 11)] + fn test_allocator_duplicate_id( + #[case] first_size: u64, + #[case] id: u64, + #[case] second_size: u64, + #[case] _second_id: u64, + ) { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + + // Allocate with an ID + allocator.allocate(first_size, id).unwrap(); + // Try to allocate again with the same ID + assert_eq!(allocator.allocate(second_size, id), Err(libc::EADDRINUSE)); + } + + #[test] + fn test_allocator_release_nonexistent() { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + assert_eq!(allocator.release(99), Err(libc::EINVAL)); + } + + #[test] + fn test_address_range_empty_and_is_empty() { + let empty = AddressRange::empty(); + assert!(empty.is_empty()); + + let non_empty = AddressRange::from_range(0, 0); + assert!(!non_empty.is_empty()); + + let also_empty = AddressRange::from_range(5, 3); + assert!(also_empty.is_empty()); + } + + #[test] + fn test_address_range_len() { + assert_eq!(AddressRange::empty().len(), Some(0)); + assert_eq!(AddressRange::from_range(0, 0).len(), Some(1)); + assert_eq!(AddressRange::from_range(0, 9).len(), Some(10)); + assert_eq!(AddressRange::from_range(100, 199).len(), Some(100)); + assert_eq!(AddressRange::from_range(0, u64::MAX).len(), None); + } + + #[test] + fn test_address_range_intersect() { + let a = AddressRange::from_range(0, 100); + let b = AddressRange::from_range(50, 200); + let c = AddressRange::from_range(101, 200); + + assert_eq!(a.intersect(b), AddressRange::from_range(50, 100)); + assert!(a.overlaps(b)); + + assert!(a.intersect(c).is_empty()); + assert!(!a.overlaps(c)); + + let d = AddressRange::from_range(0, 100); + assert_eq!(a.intersect(d), a); + } + + #[test] + fn test_address_range_non_overlapping_ranges() { + let a = AddressRange::from_range(10, 90); + let b = AddressRange::from_range(30, 60); + let (before, after) = a.non_overlapping_ranges(b); + assert_eq!(before, AddressRange::from_range(10, 29)); + assert_eq!(after, AddressRange::from_range(61, 90)); + + let (before, after) = a.non_overlapping_ranges(a); + assert!(before.is_empty()); + assert!(after.is_empty()); + } + + #[test] + fn test_allocator_new_from_list() { + let pools = vec![ + AddressRange::from_range(0, 99), + AddressRange::from_range(200, 299), + ]; + let alloc = MediaAllocator::new_from_list(pools.clone(), None).unwrap(); + assert_eq!(alloc.pools, pools); + assert_eq!(alloc.regions.len(), 2); + } + + #[test] + fn test_allocator_new_from_list_filters_empty() { + let pools = vec![ + AddressRange::from_range(0, 99), + AddressRange::empty(), + AddressRange::from_range(200, 299), + ]; + let alloc = MediaAllocator::new_from_list(pools, None).unwrap(); + assert_eq!(alloc.pools.len(), 2); + assert_eq!(alloc.regions.len(), 2); + } + + #[test] + fn test_allocator_bad_min_align() { + let pool = AddressRange::from_range(0, 1023); + assert_eq!(MediaAllocator::new(pool, Some(3)), Err(libc::EBADR)); + assert_eq!(MediaAllocator::new(pool, Some(6)), Err(libc::EBADR)); + } + + #[test] + fn test_allocator_allocate_zero_size() { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + assert_eq!(allocator.allocate(0, 1), Err(libc::EINVAL)); + } + + #[test] + fn test_allocator_release_containing() { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + + let offset = allocator.allocate(100, 42).unwrap(); + let released = allocator.release_containing(offset + 50).unwrap(); + assert_eq!(released, AddressRange::from_range(0, 99)); + assert!(allocator.allocs.is_empty()); + } + + #[test] + fn test_allocator_release_containing_not_found() { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + assert_eq!(allocator.release_containing(500), Err(libc::EFAULT)); + } + + #[rstest] + #[case( + 256, + 1, + 256, + 2, + AddressRange::from_range(0, 255), + AddressRange::from_range(512, 1023) + )] + #[case( + 128, + 1, + 128, + 2, + AddressRange::from_range(0, 127), + AddressRange::from_range(256, 1023) + )] + #[case( + 512, + 1, + 256, + 2, + AddressRange::from_range(0, 511), + AddressRange::from_range(768, 1023) + )] + fn test_allocator_coalescing( + #[case] first_size: u64, + #[case] first_id: u64, + #[case] second_size: u64, + #[case] second_id: u64, + #[case] expected_first_free: AddressRange, + #[case] expected_second_free: AddressRange, + ) { + let pool = AddressRange::from_range(0, 1023); + let mut allocator = MediaAllocator::new(pool, Some(4)).unwrap(); + + // Allocate two regions + allocator.allocate(first_size, first_id).unwrap(); + allocator.allocate(second_size, second_id).unwrap(); + + // Release the first region + allocator.release(first_id).unwrap(); + assert_eq!( + allocator.regions.iter().collect::>(), + vec![&expected_first_free, &expected_second_free] + ); + + // Release the second region, which should coalesce the free regions + allocator.release(second_id).unwrap(); + assert_eq!(allocator.regions.iter().next(), Some(&pool)); + } +} diff --git a/vhost-device-media/src/null_backend.rs b/vhost-device-media/src/null_backend.rs new file mode 100644 index 000000000..9e666d507 --- /dev/null +++ b/vhost-device-media/src/null_backend.rs @@ -0,0 +1,112 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +//! Null media backend. +//! +//! A no-op backend that presents itself as a V4L2 device but rejects all +//! media operations with `ENOTTY`. + +use std::os::fd::BorrowedFd; + +use virtio_media::{ + io::WriteToDescriptorChain, protocol::V4l2Ioctl, VirtioMediaDevice, VirtioMediaDeviceSession, +}; + +use crate::vhu_media::{Reader, Writer}; + +/// Session handle for the null backend — no file descriptor to poll. +pub struct NullSession; + +impl VirtioMediaDeviceSession for NullSession { + fn poll_fd(&self) -> Option> { + None + } +} + +/// Null media device: accepts guest connections but returns `ENOTTY` for +/// every ioctl and rejects every mmap request. +pub struct NullMediaDevice; + +impl VirtioMediaDevice for NullMediaDevice { + type Session = NullSession; + + fn new_session(&mut self, _session_id: u32) -> Result { + Ok(NullSession) + } + + fn close_session(&mut self, _session: NullSession) {} + + fn do_ioctl( + &mut self, + _session: &mut NullSession, + _ioctl: V4l2Ioctl, + _reader: &mut Reader, + writer: &mut Writer, + ) -> std::io::Result<()> { + writer.write_err_response(libc::ENOTTY) + } + + fn do_mmap( + &mut self, + _session: &mut NullSession, + _flags: u32, + _offset: u32, + ) -> Result<(u64, u64), i32> { + Err(libc::ENOTTY) + } + + fn do_munmap(&mut self, _guest_addr: u64) -> Result<(), i32> { + Ok(()) + } + + fn process_events(&mut self, _session: &mut NullSession) -> Result<(), i32> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_null_session_poll_fd_is_none() { + let session = NullSession; + assert!(session.poll_fd().is_none()); + } + + #[test] + fn test_null_device_new_session_succeeds() { + let mut device = NullMediaDevice; + device.new_session(0).unwrap(); + device.new_session(42).unwrap(); + } + + #[test] + fn test_null_device_close_session_is_noop() { + let mut device = NullMediaDevice; + let session = device.new_session(0).unwrap(); + device.close_session(session); // must not panic + } + + #[test] + fn test_null_device_do_mmap_returns_enotty() { + let mut device = NullMediaDevice; + let mut session = device.new_session(0).unwrap(); + assert_eq!(device.do_mmap(&mut session, 0, 0), Err(libc::ENOTTY)); + } + + #[test] + fn test_null_device_do_munmap_succeeds() { + let mut device = NullMediaDevice; + assert_eq!(device.do_munmap(0), Ok(())); + assert_eq!(device.do_munmap(u64::MAX), Ok(())); + } + + #[test] + fn test_null_device_process_events_succeeds() { + let mut device = NullMediaDevice; + let mut session = device.new_session(0).unwrap(); + assert_eq!(device.process_events(&mut session), Ok(())); + } +} diff --git a/vhost-device-media/src/vhu_adapters.rs b/vhost-device-media/src/vhu_adapters.rs new file mode 100644 index 000000000..83762a0ec --- /dev/null +++ b/vhost-device-media/src/vhu_adapters.rs @@ -0,0 +1,338 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{borrow::Borrow, os::fd::BorrowedFd, thread}; + +use log::warn; +use vhost::vhost_user::{ + message::{VhostUserMMap, VhostUserMMapFlags}, + Backend, VhostUserFrontendReqHandler, +}; +use vhost_user_backend::{VringRwLock, VringT}; +use virtio_media::{ + protocol::{DequeueBufferEvent, ErrorEvent, SessionEvent, SgEntry, V4l2Event}, + GuestMemoryRange, VirtioMediaEventQueue, VirtioMediaGuestMemoryMapper, + VirtioMediaHostMemoryMapper, +}; +use virtio_queue::{DescriptorChain, QueueOwnedT}; +use vm_memory::{ + atomic::GuestMemoryAtomic, mmap::GuestMemoryMmap, Bytes, GuestAddress, GuestAddressSpace, + GuestMemoryLoadGuard, +}; + +use crate::{ + media_allocator::{AddressRange, MediaAllocator}, + vhu_media::SHMEM_SIZE, +}; + +type MediaDescriptorChain = DescriptorChain>; + +#[repr(C)] +pub struct EventQueue { + pub queue: VringRwLock, + /// Guest memory map. + pub mem: GuestMemoryAtomic, +} + +impl EventQueue { + fn event(&self) -> Vec { + self.queue + .borrow() + .get_mut() + .get_queue_mut() + .iter(self.mem.memory()) + .map_or_else(|_| vec![], |iter| iter.collect()) + } +} + +impl VirtioMediaEventQueue for EventQueue { + fn send_event(&mut self, event: V4l2Event) { + let eventq = self.queue.borrow(); + let desc_chain; + loop { + if let Some(d) = self.event().pop() { + desc_chain = d; + break; + } + thread::yield_now(); + } + let descriptors: Vec<_> = desc_chain.clone().collect(); + if descriptors.len() > 1 { + warn!("Unexpected descriptor count {}", descriptors.len()); + } + if desc_chain + .memory() + .write_slice( + match event { + // SAFETY: The event types are plain C structs with no padding or + // invalid bit patterns, so it is safe to view them as a byte slice. + V4l2Event::Error(event) => unsafe { + std::slice::from_raw_parts( + (&raw const event).cast::(), + std::mem::size_of::(), + ) + }, + // SAFETY: The event types are plain C structs with no padding or + // invalid bit patterns, so it is safe to view them as a byte slice. + V4l2Event::DequeueBuffer(event) => unsafe { + std::slice::from_raw_parts( + (&raw const event).cast::(), + std::mem::size_of::(), + ) + }, + // SAFETY: The event types are plain C structs with no padding or + // invalid bit patterns, so it is safe to view them as a byte slice. + V4l2Event::Event(event) => unsafe { + std::slice::from_raw_parts( + (&raw const event).cast::(), + std::mem::size_of::(), + ) + }, + }, + descriptors[0].addr(), + ) + .is_err() + { + warn!("Failed to write event"); + return; + } + + if eventq + .add_used(desc_chain.head_index(), descriptors[0].len()) + .is_err() + { + warn!("Couldn't return used descriptors to the ring"); + } + if let Err(e) = eventq.signal_used_queue() { + warn!("Failed to signal used queue: {}", e); + } + } +} + +pub struct VuBackend { + backend: Backend, + allocator: MediaAllocator, +} + +impl VuBackend { + pub fn new(backend: Backend) -> std::result::Result { + Ok(Self { + backend, + allocator: MediaAllocator::new(AddressRange::from_range(0, SHMEM_SIZE), Some(0x1000))?, + }) + } +} + +impl VirtioMediaHostMemoryMapper for VuBackend { + fn add_mapping( + &mut self, + buffer: BorrowedFd, + length: u64, + offset: u64, + rw: bool, + ) -> std::result::Result { + let shm_offset = self.allocator.allocate(length, offset)?; + let msg = VhostUserMMap { + len: length, + flags: if rw { + VhostUserMMapFlags::WRITABLE.bits() + } else { + VhostUserMMapFlags::default().bits() + }, + shm_offset, + ..Default::default() + }; + + self.backend + .shmem_map(&msg, &buffer) + .map_err(|_| libc::EINVAL)?; + + Ok(shm_offset) + } + + fn remove_mapping(&mut self, offset: u64) -> std::result::Result<(), i32> { + let mut msg: VhostUserMMap = Default::default(); + let shm_offset = self.allocator.release_containing(offset)?; + msg.shm_offset = shm_offset.start; + msg.len = match shm_offset.len() { + Some(len) => len, + None => return Err(libc::EINVAL), + }; + self.backend.shmem_unmap(&msg).map_err(|_| libc::EINVAL)?; + + Ok(()) + } +} + +pub struct GuestMemoryMapping { + data: Vec, + mem: GuestMemoryAtomic, + sgs: Vec, + dirty: bool, +} + +impl GuestMemoryMapping { + fn new(mem: &GuestMemoryAtomic, sgs: Vec) -> std::io::Result { + let total_size = sgs.iter().fold(0, |total, sg| total + sg.len as usize); + let mut data = vec![0u8; total_size]; + let mut pos = 0; + for sg in &sgs { + mem.memory() + .read( + &mut data[pos..pos + sg.len as usize], + GuestAddress(sg.start), + ) + .map_err(std::io::Error::other)?; + pos += sg.len as usize; + } + + Ok(Self { + data, + mem: mem.clone(), + sgs, + dirty: false, + }) + } +} + +impl GuestMemoryRange for GuestMemoryMapping { + fn as_ptr(&self) -> *const u8 { + self.data.as_ptr() + } + + fn as_mut_ptr(&mut self) -> *mut u8 { + self.dirty = true; + self.data.as_mut_ptr() + } +} + +/// Write the potentially modified shadow buffer back into the guest memory. +impl Drop for GuestMemoryMapping { + fn drop(&mut self) { + // No need to copy back if no modification has been done. + if !self.dirty { + return; + } + + let mut pos = 0; + for sg in &self.sgs { + if let Err(e) = self.mem.memory().write( + &self.data[pos..pos + sg.len as usize], + GuestAddress(sg.start), + ) { + log::error!("failed to write back guest memory shadow mapping: {:#}", e); + } + pos += sg.len as usize; + } + } +} + +pub struct VuMemoryMapper(GuestMemoryAtomic); + +impl VuMemoryMapper { + pub fn new(mem: GuestMemoryAtomic) -> Self { + Self(mem) + } +} + +impl VirtioMediaGuestMemoryMapper for VuMemoryMapper { + type GuestMemoryMapping = GuestMemoryMapping; + + fn new_mapping(&self, sgs: Vec) -> anyhow::Result { + Ok(GuestMemoryMapping::new(&self.0, sgs)?) + } +} + +#[cfg(test)] +mod tests { + use vm_memory::{Bytes, GuestAddress}; + + use super::*; + + fn sg(start: u64, len: u32) -> SgEntry { + // SAFETY: SgEntry is a plain C repr POD; we initialize required public + // fields and leave the private padding zeroed. + let mut entry: SgEntry = unsafe { std::mem::zeroed() }; + entry.start = start; + entry.len = len; + entry + } + + fn test_mem() -> GuestMemoryAtomic { + GuestMemoryAtomic::new(GuestMemoryMmap::from_ranges(&[(GuestAddress(0), 0x4000)]).unwrap()) + } + + #[test] + fn test_guest_memory_mapping_new_reads_from_guest() { + let mem = test_mem(); + mem.memory() + .write_slice(&[1, 2, 3, 4], GuestAddress(0x100)) + .unwrap(); + mem.memory() + .write_slice(&[9, 8, 7], GuestAddress(0x200)) + .unwrap(); + + let sgs = vec![sg(0x100, 4), sg(0x200, 3)]; + + let mapping = GuestMemoryMapping::new(&mem, sgs).unwrap(); + assert_eq!(mapping.data, vec![1, 2, 3, 4, 9, 8, 7]); + assert!(!mapping.dirty); + } + + #[test] + fn test_guest_memory_mapping_drop_writes_back_when_dirty() { + let mem = test_mem(); + mem.memory() + .write_slice(&[10, 11, 12, 13], GuestAddress(0x300)) + .unwrap(); + + let sgs = vec![sg(0x300, 4)]; + + { + let mut mapping = GuestMemoryMapping::new(&mem, sgs).unwrap(); + let _ = mapping.as_mut_ptr(); // mark dirty + mapping.data.copy_from_slice(&[42, 43, 44, 45]); + } // Drop writes back + + let mut out = [0u8; 4]; + mem.memory() + .read_slice(&mut out, GuestAddress(0x300)) + .unwrap(); + assert_eq!(out, [42, 43, 44, 45]); + } + + #[test] + fn test_guest_memory_mapping_drop_no_write_when_clean() { + let mem = test_mem(); + mem.memory() + .write_slice(&[21, 22, 23, 24], GuestAddress(0x380)) + .unwrap(); + + let sgs = vec![sg(0x380, 4)]; + + { + let _mapping = GuestMemoryMapping::new(&mem, sgs).unwrap(); + // not marked dirty + } + + let mut out = [0u8; 4]; + mem.memory() + .read_slice(&mut out, GuestAddress(0x380)) + .unwrap(); + assert_eq!(out, [21, 22, 23, 24]); + } + + #[test] + fn test_vu_memory_mapper_new_mapping() { + let mem = test_mem(); + mem.memory() + .write_slice(&[5, 6, 7], GuestAddress(0x120)) + .unwrap(); + + let mapper = VuMemoryMapper::new(mem.clone()); + let mapping = mapper.new_mapping(vec![sg(0x120, 3)]).unwrap(); + + assert_eq!(mapping.data, vec![5, 6, 7]); + } +} diff --git a/vhost-device-media/src/vhu_media.rs b/vhost-device-media/src/vhu_media.rs new file mode 100644 index 000000000..8a0b12640 --- /dev/null +++ b/vhost-device-media/src/vhu_media.rs @@ -0,0 +1,586 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{ + convert, + io::{self, Result as IoResult}, + path::Path, + sync::{Arc, Mutex}, +}; + +use clap::ValueEnum; +use log::{debug, error, info, warn}; +use thiserror::Error as ThisError; +use vhost::vhost_user::{ + message::VhostUserShMemConfig, Backend, VhostUserProtocolFeatures, VhostUserVirtioFeatures, +}; +use vhost_user_backend::{VhostUserBackend, VringEpollHandler, VringRwLock, VringT}; +use virtio_bindings::{ + virtio_config::{VIRTIO_F_NOTIFY_ON_EMPTY, VIRTIO_F_VERSION_1}, + virtio_ring::{VIRTIO_RING_F_EVENT_IDX, VIRTIO_RING_F_INDIRECT_DESC}, +}; +use virtio_media::{protocol::VirtioMediaDeviceConfig, VirtioMediaDevice}; +use vm_memory::{mmap::GuestMemoryMmap, GuestMemoryAtomic, GuestMemoryLoadGuard}; +use vmm_sys_util::{ + epoll::EventSet, + event::{new_event_consumer_and_notifier, EventConsumer, EventFlag, EventNotifier}, +}; +use zerocopy::IntoBytes; + +use crate::{ + descriptor_chain, + vhu_adapters::{EventQueue, VuBackend, VuMemoryMapper}, + vhu_media_thread::VhostUserMediaThread, +}; + +pub(crate) type MediaResult = std::result::Result; +pub(crate) type Writer = + descriptor_chain::DescriptorChainWriter>; +pub(crate) type Reader = + descriptor_chain::DescriptorChainReader>; + +#[derive(ValueEnum, Debug, Clone, Eq, PartialEq)] +pub enum BackendType { + Null, + #[cfg(feature = "simple-capture")] + SimpleCapture, + #[cfg(feature = "v4l2-proxy")] + V4l2Proxy, + #[cfg(feature = "ffmpeg")] + FfmpegDecoder, +} + +const QUEUE_SIZE: usize = 1024; +pub const NUM_QUEUES: usize = 2; +const COMMAND_Q: u16 = 0; +pub const EVENT_Q: u16 = 1; +pub const SHMEM_SIZE: u64 = 1 << 32; + +#[derive(Debug, ThisError)] +/// Errors related to vhost-device-media daemon. +pub enum VuMediaError { + #[error("Descriptor not found")] + DescriptorNotFound, + #[error("Failed to create a used descriptor")] + AddUsedDescriptorFailed, + #[error("Notification send failed")] + SendNotificationFailed, + #[error("Can't create eventFd")] + EventFdError, + #[error("Memory allocator failed")] + MemoryAllocatorFailed, + #[error("Failed to handle event")] + HandleEventNotEpollIn, + #[error("No memory configured")] + NoMemoryConfigured, + #[error("Received event for non-registered session: {0}")] + MissingSession(u32), + #[error("Media Device Runner not initialised")] + MissingRunner, + #[error("No vhost-user request channel available")] + NoRequestChannel, + #[error("Error while processing events for session {0}: {1}")] + ProcessSessionEvent(u32, i32), +} + +impl convert::From for io::Error { + fn from(e: VuMediaError) -> Self { + io::Error::other(e) + } +} + +pub(crate) struct VuMediaBackend< + D: VirtioMediaDevice + Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +> where + D::Session: Send + Sync, +{ + config: VirtioMediaDeviceConfig, + threads: Vec>>, + exit_consumer: EventConsumer, + exit_notifier: EventNotifier, + create_device: F, +} + +impl VuMediaBackend +where + D: VirtioMediaDevice + Send + Sync, + D::Session: Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +{ + /// Create a new virtio video device for /dev/video. + pub fn new( + _video_path: &Path, + config: VirtioMediaDeviceConfig, + create_device: F, + ) -> MediaResult { + let (exit_consumer, exit_notifier) = new_event_consumer_and_notifier(EventFlag::NONBLOCK) + .map_err(|_| VuMediaError::EventFdError)?; + Ok(Self { + config, + threads: vec![Mutex::new(VhostUserMediaThread::new()?)], + exit_consumer, + exit_notifier, + create_device, + }) + } + + pub fn set_thread_workers(&self, vring_workers: &mut Vec>>>) { + for thread in self.threads.iter() { + thread + .lock() + .unwrap() + .set_vring_workers(vring_workers.remove(0)); + } + } +} + +/// VhostUserBackend trait methods +impl VhostUserBackend for VuMediaBackend +where + D: VirtioMediaDevice + Send + Sync, + D::Session: Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +{ + type Vring = VringRwLock; + type Bitmap = (); + + fn num_queues(&self) -> usize { + NUM_QUEUES + } + + fn max_queue_size(&self) -> usize { + debug!("Max queue size called"); + QUEUE_SIZE + } + + fn features(&self) -> u64 { + debug!("Features called"); + 1 << VIRTIO_F_VERSION_1 + | 1 << VIRTIO_F_NOTIFY_ON_EMPTY + | 1 << VIRTIO_RING_F_INDIRECT_DESC + | 1 << VIRTIO_RING_F_EVENT_IDX + | VhostUserVirtioFeatures::PROTOCOL_FEATURES.bits() + } + + fn protocol_features(&self) -> VhostUserProtocolFeatures { + debug!("Protocol features called"); + VhostUserProtocolFeatures::MQ + | VhostUserProtocolFeatures::CONFIG + | VhostUserProtocolFeatures::BACKEND_REQ + | VhostUserProtocolFeatures::BACKEND_SEND_FD + | VhostUserProtocolFeatures::REPLY_ACK + | VhostUserProtocolFeatures::SHMEM + } + + fn set_event_idx(&self, enabled: bool) { + for thread in self.threads.iter() { + thread.lock().unwrap().event_idx = enabled; + } + } + + fn update_memory(&self, mem: GuestMemoryAtomic) -> IoResult<()> { + info!("Memory updated - guest probably booting"); + for (i, thread) in self.threads.iter().enumerate() { + match thread.try_lock() { + Err(_) => error!( + "Thread {i} locked, dropping memory update — guest memory map is now stale" + ), + Ok(mut t) => t.mem = Some(mem.clone()), + } + } + Ok(()) + } + + fn handle_event( + &self, + device_event: u16, + evset: EventSet, + vrings: &[VringRwLock], + thread_id: usize, + ) -> IoResult<()> { + if evset != EventSet::IN { + warn!("Non-input event"); + return Err(VuMediaError::HandleEventNotEpollIn.into()); + } + let mut thread = self.threads[thread_id].lock().unwrap(); + let commandq = &vrings[COMMAND_Q as usize]; + let eventq = &vrings[EVENT_Q as usize]; + let evt_idx = thread.event_idx; + if thread.need_media_worker() { + let atomic_mem = thread.atomic_mem().map_err(io::Error::other)?.clone(); + let vu_req = thread + .vu_req + .as_ref() + .ok_or_else(|| io::Error::other(VuMediaError::NoRequestChannel))? + .clone(); + let device = (self.create_device)( + EventQueue { + mem: atomic_mem.clone(), + queue: eventq.clone(), + }, + VuMemoryMapper::new(atomic_mem), + VuBackend::new(vu_req).map_err(|_| VuMediaError::MemoryAllocatorFailed)?, + ) + .map_err(io::Error::other)?; + thread.set_media_worker(device); + } + + match device_event { + COMMAND_Q => { + if evt_idx { + // vm-virtio's Queue implementation only checks avail_index + // once, so to properly support EVENT_IDX we need to keep + // calling process_queue() until it stops finding new + // requests on the queue. + loop { + commandq.disable_notification().map_err(io::Error::other)?; + thread.process_command_queue(commandq)?; + if !commandq.enable_notification().map_err(io::Error::other)? { + break; + } + } + } else { + // Without EVENT_IDX, a single call is enough. + thread.process_command_queue(commandq)?; + } + } + + EVENT_Q => { + // We do not handle incoming events. + warn!("Unexpected event notification received"); + } + + session_id => { + let session_id = session_id as usize - (NUM_QUEUES + 1); + thread.process_media_events(session_id as u32)?; + } + } + Ok(()) + } + + fn get_config(&self, _offset: u32, _size: u32) -> Vec { + let offset = _offset as usize; + let size = _size as usize; + + let buf = self.config.as_bytes(); + + if offset + size > buf.len() { + return Vec::new(); + } + + buf[offset..offset + size].to_vec() + } + + fn exit_event(&self, _thread_index: usize) -> Option<(EventConsumer, EventNotifier)> { + let consumer = self.exit_consumer.try_clone().ok()?; + let notifier = self.exit_notifier.try_clone().ok()?; + Some((consumer, notifier)) + } + + fn set_backend_req_fd(&self, vu_req: Backend) { + debug!("Setting req fd"); + for thread in self.threads.iter() { + thread.lock().unwrap().vu_req = Some(vu_req.clone()); + } + } + + fn get_shmem_config(&self) -> IoResult { + Ok(VhostUserShMemConfig::new(1, &[SHMEM_SIZE])) + } +} + +// Shared test utilities for use across test modules +#[cfg(test)] +pub(crate) mod test_utils { + use std::os::fd::BorrowedFd; + + use virtio_media::{protocol::V4l2Ioctl, VirtioMediaDevice, VirtioMediaDeviceSession}; + + use super::*; + + pub struct DummySession {} + + impl VirtioMediaDeviceSession for DummySession { + fn poll_fd(&self) -> Option> { + None + } + } + + pub struct DummyDevice {} + + impl VirtioMediaDevice for DummyDevice { + type Session = DummySession; + + fn new_session(&mut self, _id: u32) -> std::result::Result { + Ok(DummySession {}) + } + + fn close_session(&mut self, _session: Self::Session) {} + + fn do_ioctl( + &mut self, + _session: &mut Self::Session, + _ioctl: V4l2Ioctl, + _reader: &mut Reader, + _writer: &mut Writer, + ) -> std::result::Result<(), std::io::Error> { + Ok(()) + } + + fn do_mmap( + &mut self, + _session: &mut Self::Session, + _len: u32, + _prot: u32, + ) -> std::result::Result<(u64, u64), i32> { + Ok((0, 0)) + } + + fn do_munmap(&mut self, _offset: u64) -> std::result::Result<(), i32> { + Ok(()) + } + } + + pub type DummyFn = fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult; + + pub fn make_dummy_device( + _: EventQueue, + _: VuMemoryMapper, + _: VuBackend, + ) -> MediaResult { + Ok(DummyDevice {}) + } + + pub fn create_test_config() -> VirtioMediaDeviceConfig { + VirtioMediaDeviceConfig { + device_caps: 0, + device_type: 0, + card: [0; 32], + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use rstest::*; + use vhost_user_backend::VringT; + use vm_memory::GuestAddress; + + use super::{ + test_utils::{create_test_config, make_dummy_device, DummyDevice}, + *, + }; + + #[allow(clippy::type_complexity)] + fn create_test_backend() -> VuMediaBackend< + DummyDevice, + fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult, + > { + let config = create_test_config(); + VuMediaBackend::new( + Path::new("/dev/null"), + config, + make_dummy_device + as fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult, + ) + .unwrap() + } + + fn setup_test_memory() -> GuestMemoryAtomic { + GuestMemoryAtomic::new( + GuestMemoryMmap::<()>::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(), + ) + } + + #[allow(dead_code)] // Useful helper for future tests + fn setup_test_vring(mem: &GuestMemoryAtomic, queue_size: u16) -> VringRwLock { + let vring = VringRwLock::new(mem.clone(), queue_size).unwrap(); + vring.set_queue_info(0x100, 0x200, 0x300).unwrap(); + vring.set_queue_ready(true); + vring + } + + fn setup_test_vrings(mem: &GuestMemoryAtomic) -> [VringRwLock; 2] { + let vring0 = VringRwLock::new(mem.clone(), 0x1000).unwrap(); + vring0.set_queue_info(0x100, 0x200, 0x300).unwrap(); + vring0.set_queue_ready(true); + + let vring1 = VringRwLock::new(mem.clone(), 0x2000).unwrap(); + vring1.set_queue_info(0x1100, 0x1200, 0x1300).unwrap(); + vring1.set_queue_ready(true); + + [vring0, vring1] + } + + #[test] + fn test_backend_creation_and_features() { + let backend = create_test_backend(); + + assert_eq!(backend.num_queues(), NUM_QUEUES); + assert_eq!(backend.max_queue_size(), QUEUE_SIZE); + assert_ne!(backend.features(), 0); + assert!(!backend.protocol_features().is_empty()); + } + + #[rstest] + #[case(0x12345678u32, 0, 8, 0x12345678u64)] + #[case(0x00000000u32, 0, 8, 0x00000000u64)] + #[case(0xFFFFFFFFu32, 0, 8, 0xFFFFFFFFu64)] + fn test_get_config( + #[case] device_caps: u32, + #[case] offset: u32, + #[case] size: u32, + #[case] expected: u64, + ) { + let mut config = create_test_config(); + config.device_caps = device_caps; + let backend = + VuMediaBackend::new(Path::new("/dev/null"), config, make_dummy_device).unwrap(); + + let config_bytes = backend.get_config(offset, size); + assert_eq!(config_bytes.len(), size as usize); + let mut bytes_array = [0u8; 8]; + bytes_array[..config_bytes.len()].copy_from_slice(&config_bytes); + let val = u64::from_le_bytes(bytes_array); + assert_eq!(val, expected); + } + + #[test] + fn test_get_config_partial_read() { + let mut config = create_test_config(); + config.device_caps = 0xDEADBEEF; + let backend = + VuMediaBackend::new(Path::new("/dev/null"), config, make_dummy_device).unwrap(); + + // Test reading 4 bytes + let config_bytes = backend.get_config(0, 4); + assert_eq!(config_bytes.len(), 4); + let val = u32::from_le_bytes(config_bytes.try_into().unwrap()); + assert_eq!(val, 0xDEADBEEF); + } + + #[test] + fn test_get_config_out_of_bounds() { + let mut config = create_test_config(); + config.device_caps = 0x12345678; + let backend = + VuMediaBackend::new(Path::new("/dev/null"), config, make_dummy_device).unwrap(); + + // Test reading out of bounds + let config_bytes = backend.get_config(1024, 8); + assert_eq!(config_bytes.len(), 0); + } + + #[test] + fn test_exit_event() { + let backend = create_test_backend(); + + let exit_event = backend.exit_event(0); + assert!(exit_event.is_some()); + let (consumer, notifier) = exit_event.unwrap(); + notifier.notify().unwrap(); + consumer.try_clone().unwrap(); + } + + #[test] + fn test_handle_event() { + let backend = create_test_backend(); + let mem = setup_test_memory(); + let vrings = setup_test_vrings(&mem); + + backend.update_memory(mem).unwrap(); + + // Test a non-IN event + assert!(backend + .handle_event(COMMAND_Q, EventSet::OUT, &vrings, 0) + .is_err()); + + // TODO: We intentionally do not test the IN-path here because it + // requires a fully initialized backend request fd and worker + // setup. + } + + #[test] + fn test_vu_media_error_to_io_error() { + let err = VuMediaError::DescriptorNotFound; + let io_err: io::Error = err.into(); + assert_eq!(io_err.kind(), io::ErrorKind::Other); + assert!(io_err.to_string().contains("Descriptor not found")); + + let err = VuMediaError::MissingSession(42); + let io_err: io::Error = err.into(); + assert!(io_err.to_string().contains("42")); + + let err = VuMediaError::ProcessSessionEvent(7, -1); + let io_err: io::Error = err.into(); + assert!(io_err.to_string().contains("7")); + } + + #[test] + fn test_vu_media_error_display() { + assert_eq!( + VuMediaError::DescriptorNotFound.to_string(), + "Descriptor not found" + ); + assert_eq!( + VuMediaError::AddUsedDescriptorFailed.to_string(), + "Failed to create a used descriptor" + ); + assert_eq!( + VuMediaError::SendNotificationFailed.to_string(), + "Notification send failed" + ); + assert_eq!( + VuMediaError::EventFdError.to_string(), + "Can't create eventFd" + ); + assert_eq!( + VuMediaError::MemoryAllocatorFailed.to_string(), + "Memory allocator failed" + ); + assert_eq!( + VuMediaError::HandleEventNotEpollIn.to_string(), + "Failed to handle event" + ); + assert_eq!( + VuMediaError::NoMemoryConfigured.to_string(), + "No memory configured" + ); + assert_eq!( + VuMediaError::MissingRunner.to_string(), + "Media Device Runner not initialised" + ); + } + + #[test] + fn test_get_shmem_config() { + let backend = create_test_backend(); + let shmem = backend.get_shmem_config().unwrap(); + assert_eq!(shmem.nregions, 1); + assert_eq!(shmem.memory_sizes[0], SHMEM_SIZE); + } + + #[test] + fn test_set_event_idx() { + let backend = create_test_backend(); + + backend.set_event_idx(true); + assert!(backend.threads[0].lock().unwrap().event_idx); + + backend.set_event_idx(false); + assert!(!backend.threads[0].lock().unwrap().event_idx); + } + + #[test] + fn test_update_memory() { + let backend = create_test_backend(); + let mem = setup_test_memory(); + + backend.update_memory(mem).unwrap(); + assert!(backend.threads[0].lock().unwrap().mem.is_some()); + } +} diff --git a/vhost-device-media/src/vhu_media_thread.rs b/vhost-device-media/src/vhu_media_thread.rs new file mode 100644 index 000000000..30beb6374 --- /dev/null +++ b/vhost-device-media/src/vhu_media_thread.rs @@ -0,0 +1,325 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{ + os::{fd::BorrowedFd, unix::io::AsRawFd}, + sync::Arc, +}; + +use vhost::vhost_user::Backend; +use vhost_user_backend::{VringEpollHandler, VringRwLock, VringT}; +use virtio_media::{poll::SessionPoller, VirtioMediaDevice, VirtioMediaDeviceRunner}; +use virtio_queue::QueueOwnedT; +use vm_memory::{GuestAddressSpace, GuestMemoryAtomic, GuestMemoryMmap}; +use vmm_sys_util::epoll::EventSet; + +use crate::{ + vhu_adapters::{EventQueue, VuBackend, VuMemoryMapper}, + vhu_media::{MediaResult, Reader, VuMediaBackend, VuMediaError, Writer, NUM_QUEUES}, +}; + +struct MediaSession< + D: VirtioMediaDevice + Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +> where + D::Session: Send + Sync, +{ + epoll_handler: Arc>>>, +} + +impl MediaSession +where + D: VirtioMediaDevice + Send + Sync, + D::Session: Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +{ + pub fn new(epoll_handler: Arc>>>) -> Self { + Self { epoll_handler } + } +} + +impl SessionPoller for MediaSession +where + D: VirtioMediaDevice + Send + Sync, + D::Session: Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +{ + fn add_session(&self, session: BorrowedFd, session_id: u32) -> Result<(), i32> { + self.epoll_handler + .register_listener( + session.as_raw_fd(), + EventSet::IN, + // Event range [0...num_queues] is reserved for queues and exit event. + // So registered session start at NUM_QUEUES + 1. + u64::from((NUM_QUEUES + 1) as u32 + session_id), + ) + .map_err(|e| e.kind() as i32) + } + + fn remove_session(&self, session: BorrowedFd) { + let _ = + self.epoll_handler + .as_ref() + .unregister_listener(session.as_raw_fd(), EventSet::IN, 0); + } +} + +impl Clone for MediaSession +where + D: VirtioMediaDevice + Send + Sync, + D::Session: Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +{ + fn clone(&self) -> Self { + Self { + epoll_handler: Arc::clone(&self.epoll_handler), + } + } +} + +pub(crate) struct VhostUserMediaThread< + D: VirtioMediaDevice + Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +> where + D::Session: Send + Sync, +{ + /// Guest memory map. + pub mem: Option>, + /// VIRTIO_RING_F_EVENT_IDX. + pub event_idx: bool, + epoll_handler: Option>, + pub vu_req: Option, + worker: Option>>, +} + +impl VhostUserMediaThread +where + D: VirtioMediaDevice + Send + Sync, + D::Session: Send + Sync, + F: Fn(EventQueue, VuMemoryMapper, VuBackend) -> MediaResult + Send + Sync, +{ + pub fn new() -> MediaResult { + Ok(Self { + mem: None, + event_idx: false, + epoll_handler: None, + vu_req: None, + worker: None, + }) + } + + pub fn set_vring_workers( + &mut self, + epoll_handler: Arc>>>, + ) { + self.epoll_handler = Some(MediaSession::new(epoll_handler)); + } + + pub fn need_media_worker(&self) -> bool { + self.worker.is_none() + } + + pub fn set_media_worker(&mut self, device: D) { + let worker = self.epoll_handler.as_ref().unwrap(); + self.worker = Some(VirtioMediaDeviceRunner::new(device, worker.clone())); + } + + pub fn process_media_events(&mut self, session_id: u32) -> MediaResult<()> { + if let Some(runner) = self.worker.as_mut() { + let session = runner + .sessions + .get_mut(&session_id) + .ok_or(VuMediaError::MissingSession(session_id))?; + if let Err(e) = runner.device.process_events(session) { + if let Some(session) = runner.sessions.remove(&session_id) { + runner.device.close_session(session); + } + return Err(VuMediaError::ProcessSessionEvent(session_id, e)); + } + + return Ok(()); + } + + Err(VuMediaError::MissingRunner) + } + + pub fn atomic_mem(&self) -> MediaResult<&GuestMemoryAtomic> { + match &self.mem { + Some(m) => Ok(m), + None => Err(VuMediaError::NoMemoryConfigured), + } + } + + pub fn process_command_queue(&mut self, vring: &VringRwLock) -> MediaResult<()> { + let chains: Vec<_> = vring + .get_mut() + .get_queue_mut() + .iter(self.atomic_mem()?.memory()) + .map_err(|_| VuMediaError::DescriptorNotFound)? + .collect(); + + for dc in chains { + let mut writer = Writer::new(dc.clone()); + let mut reader = Reader::new(dc.clone()); + + if let Some(runner) = &mut self.worker { + runner.handle_command(&mut reader, &mut writer); + } + + vring + .add_used(dc.head_index(), writer.max_written()) + .map_err(|_| VuMediaError::AddUsedDescriptorFailed)?; + } + + vring + .signal_used_queue() + .map_err(|_| VuMediaError::SendNotificationFailed)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{os::fd::AsRawFd, sync::Arc}; + + use assert_matches::assert_matches; + use rstest::*; + use vhost_user_backend::VhostUserDaemon; + use virtio_media::poll::SessionPoller; + use vm_memory::GuestAddress; + use vmm_sys_util::eventfd::EventFd; + + use super::*; + use crate::vhu_media::test_utils::{ + create_test_config, make_dummy_device, DummyDevice, DummyFn, + }; + + fn setup_test_memory() -> GuestMemoryAtomic { + GuestMemoryAtomic::new( + GuestMemoryMmap::<()>::from_ranges(&[(GuestAddress(0), 0x10000)]).unwrap(), + ) + } + + fn setup_test_vring(mem: &GuestMemoryAtomic) -> VringRwLock { + let vring = VringRwLock::new(mem.clone(), 0x1000).unwrap(); + vring.set_queue_info(0x100, 0x200, 0x300).unwrap(); + vring.set_queue_ready(true); + vring + } + + #[allow(clippy::type_complexity)] + fn setup_test_backend_and_daemon() -> ( + Arc>, + VhostUserDaemon>>, + ) { + let config = create_test_config(); + let backend = Arc::new( + crate::vhu_media::VuMediaBackend::new( + std::path::Path::new("/dev/null"), + config, + make_dummy_device as DummyFn, + ) + .unwrap(), + ); + let daemon = VhostUserDaemon::new( + "vhost-device-media-test".to_owned(), + backend.clone(), + GuestMemoryAtomic::new(GuestMemoryMmap::new()), + ) + .unwrap(); + (backend, daemon) + } + + #[fixture] + fn dummy_eventfd() -> EventFd { + EventFd::new(0).expect("Could not create an EventFd") + } + + #[rstest] + #[case::no_memory(VuMediaError::NoMemoryConfigured)] + #[case::missing_runner(VuMediaError::MissingRunner)] + fn test_error_handling(#[case] expected_error: VuMediaError) { + let mut thread = VhostUserMediaThread::::new().unwrap(); + + match expected_error { + VuMediaError::NoMemoryConfigured => { + // Test atomic_mem before initialization + assert_matches!(thread.atomic_mem(), Err(VuMediaError::NoMemoryConfigured)); + } + VuMediaError::MissingRunner => { + // Test process_media_events before worker is set + assert_matches!( + thread.process_media_events(0), + Err(VuMediaError::MissingRunner) + ); + } + _ => unreachable!(), + } + } + + #[test] + fn test_queue_processing() { + let mem = setup_test_memory(); + let vring = setup_test_vring(&mem); + + let mut thread = VhostUserMediaThread::::new().unwrap(); + thread.mem = Some(mem); + + // We can't easily check the used length here without more mocking, + // but we can at least verify that the method runs without panicking. + thread.process_command_queue(&vring).unwrap(); + } + + #[test] + fn test_set_workers_and_missing_session_path() { + let (_backend, daemon) = setup_test_backend_and_daemon(); + let mut handlers = daemon.get_epoll_handlers(); + + let mut thread = VhostUserMediaThread::::new().unwrap(); + assert!(thread.need_media_worker()); + thread.set_vring_workers(handlers.remove(0)); + thread.set_media_worker(DummyDevice {}); + assert!(!thread.need_media_worker()); + + assert_matches!( + thread.process_media_events(42), + Err(VuMediaError::MissingSession(42)) + ); + } + + #[rstest] + #[case::session_0(0)] + #[case::session_7(7)] + #[case::session_42(42)] + #[case::session_100(100)] + fn test_media_session_add_remove_session(dummy_eventfd: EventFd, #[case] session_id: u32) { + let (_backend, daemon) = setup_test_backend_and_daemon(); + let mut handlers = daemon.get_epoll_handlers(); + let session_poller = MediaSession::new(handlers.remove(0)); + + // SAFETY: `borrowed` does not outlive `dummy_eventfd` in this test. + let borrowed = unsafe { BorrowedFd::borrow_raw(dummy_eventfd.as_raw_fd()) }; + assert_matches!(session_poller.add_session(borrowed, session_id), Ok(())); + session_poller.remove_session(borrowed); + } + + #[rstest] + #[case::session_0(0)] + #[case::session_1(1)] + #[case::session_99(99)] + fn test_process_media_events_missing_session(#[case] session_id: u32) { + let (_backend, daemon) = setup_test_backend_and_daemon(); + let mut handlers = daemon.get_epoll_handlers(); + + let mut thread = VhostUserMediaThread::::new().unwrap(); + thread.set_vring_workers(handlers.remove(0)); + thread.set_media_worker(DummyDevice {}); + + assert_matches!( + thread.process_media_events(session_id), + Err(VuMediaError::MissingSession(id)) if id == session_id + ); + } +}