diff --git a/Cargo.lock b/Cargo.lock index 683d6aa3..20934c58 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.0", + "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,9 +171,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -219,9 +239,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.6" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -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" @@ -247,7 +273,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures", - "rand_core 0.10.0", + "rand_core", ] [[package]] @@ -292,7 +318,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -327,7 +353,7 @@ dependencies = [ "libc 0.2.182", "once_cell", "unicode-width", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -409,7 +435,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -448,6 +474,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.0" @@ -494,7 +531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc 0.2.182", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -560,9 +597,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] @@ -592,7 +629,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -628,26 +665,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc 0.2.182", - "r-efi", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc 0.2.182", "r-efi", - "rand_core 0.10.0", + "rand_core", "wasip2", "wasip3", ] @@ -662,7 +687,7 @@ dependencies = [ "gobject-sys", "libc 0.2.182", "system-deps", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -696,7 +721,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -931,9 +956,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819b44bc7c87d9117eb522f14d46e918add69ff12713c475946b0a29363ed1c2" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -944,13 +969,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470252db18ecc35fd766c0891b1e3ec6cbbcd62507e85276c01bf75d8e94d4a1" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -986,9 +1011,9 @@ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[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" @@ -1009,7 +1034,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", ] @@ -1046,7 +1071,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", ] @@ -1132,7 +1157,7 @@ dependencies = [ "libc 0.2.182", "log", "wasi", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1158,7 +1183,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1198,6 +1223,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.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc 0.2.182", +] + [[package]] name = "nix" version = "0.29.0" @@ -1206,7 +1243,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc 0.2.182", "memoffset", ] @@ -1219,9 +1256,8 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc 0.2.182", - "memoffset", ] [[package]] @@ -1232,8 +1268,9 @@ checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc 0.2.182", + "memoffset", ] [[package]] @@ -1283,6 +1320,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1327,6 +1386,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.1" @@ -1362,7 +1427,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", ] @@ -1388,15 +1453,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "predicates" version = "3.1.4" @@ -1430,14 +1486,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -1465,18 +1521,18 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radium" @@ -1484,16 +1540,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.0" @@ -1501,42 +1547,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ "chacha20", - "getrandom 0.4.1", - "rand_core 0.10.0", + "getrandom", + "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] -name = "rand_core" -version = "0.9.5" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "getrandom 0.3.4", + "bitflags 2.11.0", ] [[package]] -name = "rand_core" -version = "0.10.0" +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "ref-cast-impl" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ - "bitflags 2.11.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1582,7 +1629,7 @@ checksum = "d7ef12e84481ab4006cb942f8682bba28ece7270743e649442027c5db87df126" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1616,10 +1663,16 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.106", + "syn 2.0.117", "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.1" @@ -1645,7 +1698,7 @@ dependencies = [ "errno", "libc 0.2.182", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1729,7 +1782,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1870,9 +1923,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1911,10 +1964,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom", "once_cell", "rustix", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1949,7 +2002,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] @@ -1960,19 +2013,19 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -1987,14 +2040,23 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "winnow", ] @@ -2067,16 +2129,47 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.1", + "getrandom", "js-sys", - "rand 0.9.2", + "rand", "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.0", + "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.0", + "enumn", + "log", + "nix 0.28.0", + "paste", + "thiserror 1.0.69", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -2102,6 +2195,18 @@ dependencies = [ "vmm-sys-util", ] +[[package]] +name = "vhost" +version = "0.15.0" +source = "git+https://github.com/rust-vmm/vhost?branch=main#c9b80a1c93bac7820e4aee4269aa904568937035" +dependencies = [ + "bitflags 2.11.0", + "libc 0.2.182", + "uuid", + "vm-memory", + "vmm-sys-util", +] + [[package]] name = "vhost-device-can" version = "0.1.0" @@ -2113,8 +2218,8 @@ dependencies = [ "queues", "socketcan", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2134,8 +2239,8 @@ dependencies = [ "log", "queues", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2153,8 +2258,8 @@ dependencies = [ "libgpiod", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2176,8 +2281,8 @@ dependencies = [ "rutabaga_gfx", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virglrenderer", "virtio-bindings", "virtio-queue", @@ -2195,8 +2300,8 @@ dependencies = [ "libc 0.2.182", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2215,15 +2320,45 @@ dependencies = [ "libc 0.2.182", "log", "nix 0.31.2", - "rand 0.10.0", + "rand", + "tempfile", + "thiserror 2.0.18", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", + "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.0", + "clap", + "env_logger", + "epoll", + "futures-executor", + "libc 0.2.182", + "log", + "num_enum", + "ref-cast", + "rstest", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "v4l2r 0.0.7 (git+https://github.com/Gnurou/v4l2r?rev=7b44138)", + "vhost 0.15.0 (git+https://github.com/rust-vmm/vhost?branch=main)", + "vhost-user-backend 0.21.0 (git+https://github.com/rust-vmm/vhost?branch=main)", "virtio-bindings", + "virtio-media", + "virtio-media-ffmpeg-decoder", "virtio-queue", "vm-memory", "vmm-sys-util", + "zerocopy", ] [[package]] @@ -2236,11 +2371,11 @@ dependencies = [ "epoll", "libc 0.2.182", "log", - "rand 0.10.0", + "rand", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2257,8 +2392,8 @@ dependencies = [ "itertools 0.14.0", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2276,8 +2411,8 @@ dependencies = [ "log", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2296,13 +2431,13 @@ dependencies = [ "gstreamer-audio", "log", "pipewire", - "rand 0.10.0", + "rand", "rstest", "rusty-fork", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2320,8 +2455,8 @@ dependencies = [ "libc 0.2.182", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2338,8 +2473,8 @@ dependencies = [ "libc 0.2.182", "log", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2361,8 +2496,8 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.18", - "vhost", - "vhost-user-backend", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "vhost-user-backend 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)", "virtio-bindings", "virtio-queue", "virtio-vsock", @@ -2379,7 +2514,21 @@ checksum = "783587813a59c42c36519a6e12bb31eb2d7fa517377428252ba4cc2312584243" dependencies = [ "libc 0.2.182", "log", - "vhost", + "vhost 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", + "virtio-bindings", + "virtio-queue", + "vm-memory", + "vmm-sys-util", +] + +[[package]] +name = "vhost-user-backend" +version = "0.21.0" +source = "git+https://github.com/rust-vmm/vhost?branch=main#c9b80a1c93bac7820e4aee4269aa904568937035" +dependencies = [ + "libc 0.2.182", + "log", + "vhost 0.15.0 (git+https://github.com/rust-vmm/vhost?branch=main)", "virtio-bindings", "virtio-queue", "vm-memory", @@ -2392,7 +2541,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", @@ -2404,15 +2553,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.182", + "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.182", + "log", + "nix 0.28.0", + "pkg-config", + "thiserror 1.0.69", + "virtio-media", +] [[package]] name = "virtio-queue" @@ -2464,12 +2644,12 @@ dependencies = [ [[package]] name = "vsock" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2da6e4ac76cd19635dce0f98985378bb62f8044ee2ff80abd2a7334b920ed63" +checksum = "b82aeb12ad864eb8cd26a6c21175d0bdc66d398584ee6c93c76964c3bcfc78ff" dependencies = [ "libc 0.2.182", - "nix 0.30.1", + "nix 0.31.2", ] [[package]] @@ -2537,7 +2717,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2623,9 +2803,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", ] @@ -2696,9 +2876,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -2733,7 +2913,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.106", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2749,7 +2929,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2827,7 +3007,7 @@ checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1a2afb53..eed97b66 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-scsi", "vhost-device-scmi", diff --git a/vhost-device-media/CHANGELOG.md b/vhost-device-media/CHANGELOG.md new file mode 100644 index 00000000..9c284021 --- /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 00000000..1ee16dad --- /dev/null +++ b/vhost-device-media/Cargo.toml @@ -0,0 +1,48 @@ +[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" + +# 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"] + +[dependencies] +bitflags = "2.6.0" +clap = { version = "4.5.20", features = ["derive"] } +env_logger = "0.11.5" +epoll = "4.3.3" +num_enum = "0.7.3" +log = "0.4.22" +libc = "0.2.162" +thiserror = "2.0.3" +anyhow = "1.0.93" +futures-executor = { version = "0.3.31", features = ["thread-pool"] } +vhost = { git = "https://github.com/rust-vmm/vhost", branch = "main", features = ["vhost-user-backend"] } +vhost-user-backend = { git = "https://github.com/rust-vmm/vhost", branch = "main"} +virtio-bindings = "0.2.5" +virtio-queue = "0.17.0" +vm-memory = { version = "0.17.1", features = ["backend-mmap", "backend-atomic"] } +vmm-sys-util = "0.15.0" +ref-cast = "1.0.23" +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 = { version = "0.17.0", features = ["test-utils"] } +vm-memory = { version = "0.17.1", features = ["backend-mmap", "backend-atomic"] } +zerocopy = { version = "0.8.27", features = ["derive"] } diff --git a/vhost-device-media/LICENSE-APACHE b/vhost-device-media/LICENSE-APACHE new file mode 120000 index 00000000..965b606f --- /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 00000000..f2b07913 --- /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 00000000..1dca9855 --- /dev/null +++ b/vhost-device-media/README.md @@ -0,0 +1,72 @@ +# 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 + + +## Options + +```text + --socket-path + vhost-user Unix domain socket path + + --v4l2-device + Path to the V4L2 media device file. Defaults to /dev/video0. + + --backend + Media backend to be used. [possible values: simple-capture, v4l2-proxy, ffmpeg-decoder] + + -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 following backends are available: + +- **simple-capture**: A simple video capture device generating a pattern, purely software-based and thus not requiring any kind of hardware. Can be used for testing purposes. +- **v4l2-proxy**: A proxy device for host V4L2 devices, i.e. a device allowing to expose a host V4L2 device to the guest almost as-is. +- **ffmpeg-decoder**: A software-based video decoder backend using FFmpeg. + +## 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/lib.rs b/vhost-device-media/src/lib.rs new file mode 100644 index 00000000..ee3fa4a8 --- /dev/null +++ b/vhost-device-media/src/lib.rs @@ -0,0 +1,351 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +mod media_allocator; +mod media_backends; +pub mod vhu_media; +mod vhu_media_thread; +mod virtio; + +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; + +#[cfg(any(feature = "simple-capture", feature = "ffmpeg"))] +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)] +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 { + 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("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) -> VirtioMediaDeviceConfig { + 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(), + ) + .unwrap(); + 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[0..std::cmp::min(card.len(), config.card.len())].as_bytes(); + config.card.as_mut_slice()[0..name_slice.len()].copy_from_slice(name_slice); + + 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()), + ) + .unwrap(); + + 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()), + ) + .unwrap(); + + 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()), + ) + .unwrap(); + + vu_media_backend.set_thread_workers(&mut daemon.get_epoll_handlers()); + daemon + .serve(&media_config.socket_path) + .map_err(Error::ServeFailed)?; + + Ok(()) +} + +pub fn start_backend(media_config: VuMediaConfig) -> Result<()> { + loop { + debug!("Starting backend"); + match media_config.backend { + #[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)] +mod tests { + use std::path::Path; + + use rstest::*; + use tempfile::tempdir; + #[cfg(any(feature = "simple-capture", feature = "ffmpeg"))] + use virtio_media::protocol::VirtioMediaDeviceConfig; + + use super::*; + + #[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 + ); + } + + #[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 + ); + } + + #[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 + ); + } + + #[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); + } +} diff --git a/vhost-device-media/src/main.rs b/vhost-device-media/src/main.rs new file mode 100644 index 00000000..bf8cdfe7 --- /dev/null +++ b/vhost-device-media/src/main.rs @@ -0,0 +1,170 @@ +// 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 { + use rstest::*; + + use super::*; + + #[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); + } + + #[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. + assert!(res.is_err()); + } + } + + #[test] + fn test_media_args_parse_missing_socket_fails() { + let res = MediaArgs::try_parse_from(["vhost-device-media"]); + assert!(res.is_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", + ]); + assert!(res.is_err()); + } +} diff --git a/vhost-device-media/src/media_allocator.rs b/vhost-device-media/src/media_allocator.rs new file mode 100644 index 00000000..93f90305 --- /dev/null +++ b/vhost-device-media/src/media_allocator.rs @@ -0,0 +1,437 @@ +// 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.clone()); + } + 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), + } + .map_or(false, |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(); + // Release a non-existent allocation + assert_eq!(allocator.release(99), Err(libc::EINVAL)); + } + + #[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/media_backends.rs b/vhost-device-media/src/media_backends.rs new file mode 100644 index 00000000..27d11324 --- /dev/null +++ b/vhost-device-media/src/media_backends.rs @@ -0,0 +1,333 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{borrow::Borrow, os::fd::BorrowedFd}; + +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()) + .unwrap() + .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; + } + } + 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 { + V4l2Event::Error(event) => unsafe { + std::slice::from_raw_parts( + &event as *const _ as *const u8, + std::mem::size_of::(), + ) + }, + V4l2Event::DequeueBuffer(event) => unsafe { + std::slice::from_raw_parts( + &event as *const _ as *const u8, + std::mem::size_of::(), + ) + }, + V4l2Event::Event(event) => unsafe { + std::slice::from_raw_parts( + &event as *const _ as *const u8, + 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 mut msg: VhostUserMMap = Default::default(); + msg.len = length; + msg.flags = if rw { + VhostUserMMapFlags::WRITABLE.bits() + } else { + VhostUserMMapFlags::default().bits() + }; + msg.shm_offset = shm_offset; + + 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) -> anyhow::Result { + let total_size = sgs.iter().fold(0, |total, sg| total + sg.len as usize); + let mut data = Vec::with_capacity(total_size); + // Safe because we are about to write `total_size` bytes of data. + // This is not ideal and we should use `spare_capacity_mut` instead but the + // methods of `MaybeUnint` that would make it possible to use that with + // `read_exact_at_addr` are still in nightly. + unsafe { data.set_len(total_size) }; + let mut pos = 0; + for sg in &sgs { + mem.memory().read( + &mut data[pos..pos + sg.len as usize], + GuestAddress(sg.start), + )?; + 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 { 0: mem } + } +} + +impl VirtioMediaGuestMemoryMapper for VuMemoryMapper { + type GuestMemoryMapping = GuestMemoryMapping; + + fn new_mapping(&self, sgs: Vec) -> anyhow::Result { + 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 00000000..4dc0490e --- /dev/null +++ b/vhost-device-media/src/vhu_media.rs @@ -0,0 +1,493 @@ +// 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, 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::{ + media_backends::{EventQueue, VuBackend, VuMemoryMapper}, + vhu_media_thread::VhostUserMediaThread, + virtio, +}; + +pub(crate) type MediaResult = std::result::Result; +pub(crate) type Writer = virtio::DescriptorChainWriter>; +pub(crate) type Reader = virtio::DescriptorChainReader>; + +#[derive(ValueEnum, Debug, Clone, Eq, PartialEq)] +pub enum BackendType { + #[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("Error while processing events for session {0}: {1}")] + ProcessSessionEvent(u32, i32), +} + +impl convert::From for io::Error { + fn from(e: VuMediaError) -> Self { + io::Error::new(io::ErrorKind::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 thread in self.threads.iter() { + match thread.try_lock() { + Err(_) => warn!("Thread locked, memory update failed"), + 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 device = (self.create_device)( + EventQueue { + mem: thread.mem.as_ref().unwrap().clone(), + queue: eventq.clone(), + }, + VuMemoryMapper::new(thread.atomic_mem().unwrap().clone()), + VuBackend::new(thread.vu_req.as_ref().unwrap().clone()) + .map_err(|_| VuMediaError::MemoryAllocatorFailed)?, + ) + .unwrap(); + 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().unwrap(); + thread.process_command_queue(commandq)?; + if !commandq.enable_notification().unwrap() { + 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}, + *, + }; + + 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(); + assert!(consumer.try_clone().is_ok()); + } + + #[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. + } +} 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 00000000..09f0fc2c --- /dev/null +++ b/vhost-device-media/src/vhu_media_thread.rs @@ -0,0 +1,324 @@ +// 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::{ + media_backends::{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 + } + + 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. + assert!(thread.process_command_queue(&vring).is_ok()); + } + + #[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 + ); + } +} diff --git a/vhost-device-media/src/virtio.rs b/vhost-device-media/src/virtio.rs new file mode 100644 index 00000000..c6c9c83e --- /dev/null +++ b/vhost-device-media/src/virtio.rs @@ -0,0 +1,283 @@ +// Copyright 2026 Red Hat Inc +// +// SPDX-License-Identifier: Apache-2.0 or BSD-3-Clause + +use std::{ + cell::Cell, + cmp::{max, min}, + io, + io::{ErrorKind, Read, Write}, + ops::Deref, + rc::Rc, +}; + +use virtio_queue::{desc::split::Descriptor, DescriptorChain, DescriptorChainRwIter}; +use vm_memory::{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(|e| io::Error::new(ErrorKind::Other, e))?; + + 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 read = self + .chain + .memory() + .read( + &mut buf[..(to_read as usize)], + GuestAddress(current.addr().0 + u64::from(self.offset)), + ) + .map_err(|e| io::Error::new(ErrorKind::Other, e))?; + + 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); + } + + #[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); + } +}