diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e43ee96..09145a5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,9 +119,21 @@ jobs: override: true - name: Install libvncserver-dev run: sudo apt install -y libvncserver-dev - - uses: actions-rs/cargo@v1 + - name: Test with all features turned off + uses: actions-rs/cargo@v1 + with: + command: test + args: --no-default-features + - name: Test with all features turned on + uses: actions-rs/cargo@v1 with: command: test + args: --all-features + - name: Test vnc features + uses: actions-rs/cargo@v1 + with: + command: test + args: --no-default-features --features vnc run_build: name: Build for ${{ matrix.target }} @@ -139,9 +151,9 @@ jobs: - target: x86_64-apple-darwin os: macos-latest file-suffix: "" - # - target: aarch64-apple-darwin - # os: macos-latest - # file-suffix: "" + - target: aarch64-apple-darwin + os: macos-latest + file-suffix: "" steps: - uses: actions/checkout@v3 - uses: actions-rs/toolchain@v1 @@ -150,6 +162,8 @@ jobs: toolchain: nightly target: ${{ matrix.target }} override: true + - name: Print CPU architecture + run: uname -m && uname -a - if: runner.os == 'Linux' run: sudo apt install -y libvncserver-dev - if: runner.os == 'macOS' @@ -163,7 +177,16 @@ jobs: with: command: build args: --target=${{ matrix.target }} --no-default-features - - uses: actions-rs/cargo@v1 - with: - command: build - args: --target=${{ matrix.target }} --all-features + # Currently the macOS build is broken because of + # + # error: failed to run custom build command for `vncserver v0.2.2 (https://github.com/sbernauer/libvnc-rs.git#2bf903ad)` + # thread 'main' panicked at /Users/runner/.cargo/git/checkouts/libvnc-rs-5fba04f2b7022219/2bf903a/vncserver/build.rs:9:66: + # called `Result::unwrap()` on an `Err` value: pkg-config has not been configured to support cross-compilation. + # + # This is because GitHub switched their macOS runners to use arm, and they currently can not cross-compile to x86. + # What a time to be alive! + # + # - uses: actions-rs/cargo@v1 + # with: + # command: build + # args: --target=${{ matrix.target }} --all-features diff --git a/Cargo.lock b/Cargo.lock index f1b35ee..2847d18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,47 +55,48 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.13" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" [[package]] name = "anstyle-parse" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", "windows-sys 0.52.0", @@ -109,9 +110,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "autocfg" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" @@ -171,29 +172,50 @@ checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "breakwater" -version = "0.1.0" +version = "0.12.0" dependencies = [ + "breakwater-core", + "breakwater-parser", "chrono", "clap", "const_format", - "criterion", "env_logger", - "lazy_static", "log", "number_prefix", - "pixelbomber", "prometheus_exporter", - "rand", "rstest", "rusttype", "serde", "serde_json", "simple_moving_average", + "snafu", "thread-priority", "tokio", "vncserver", ] +[[package]] +name = "breakwater-core" +version = "0.12.0" +dependencies = [ + "const_format", + "tokio", +] + +[[package]] +name = "breakwater-parser" +version = "0.12.0" +dependencies = [ + "breakwater-core", + "criterion", + "enum_dispatch", + "memchr", + "pixelbomber", + "snafu", + "tokio", + "trait-variant", +] + [[package]] name = "bufstream" version = "0.1.4" @@ -202,15 +224,15 @@ checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" [[package]] name = "bumpalo" -version = "3.15.4" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" +checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" [[package]] name = "byteorder" @@ -232,9 +254,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.0.91" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd97381a8cc6493395a5afc4c691c1084b3768db713b73aa215217aa245d153" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" [[package]] name = "cexpr" @@ -259,16 +281,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -343,7 +365,7 @@ version = "4.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -363,9 +385,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "const_format" @@ -482,9 +504,21 @@ dependencies = [ [[package]] name = "either" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] [[package]] name = "env_filter" @@ -511,9 +545,9 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", @@ -546,9 +580,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.28" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" dependencies = [ "crc32fast", "miniz_oxide", @@ -675,9 +709,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.13" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06fddc2749e0528d2813f95e050e87e52c8cbbae56223b9babf73b3e53b0cc6" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if 1.0.0", "libc", @@ -716,6 +750,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -805,6 +845,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + [[package]] name = "itertools" version = "0.10.5" @@ -867,9 +913,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" [[package]] name = "libloading" @@ -878,7 +924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -889,9 +935,9 @@ checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -965,9 +1011,9 @@ checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -1020,9 +1066,9 @@ dependencies = [ [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" dependencies = [ "lock_api", "parking_lot_core", @@ -1030,15 +1076,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if 1.0.0", "libc", "redox_syscall", "smallvec", - "windows-targets 0.48.5", + "windows-targets 0.52.5", ] [[package]] @@ -1133,9 +1179,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "prettyplease" -version = "0.2.17" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3928fb5db768cb86f891ff014f0144589297e3c6a1aba6ed7cecfdace270c7" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", "syn", @@ -1143,18 +1189,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" dependencies = [ "unicode-ident", ] [[package]] name = "prometheus" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "449811d15fbdf5ceb5c1144416066429cf82316e2ec8ce0c1f6f8a02e7bbcf8c" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" dependencies = [ "cfg-if 1.0.0", "fnv", @@ -1189,9 +1235,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.35" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -1248,11 +1294,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.5.0", ] [[package]] @@ -1286,15 +1332,15 @@ checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" [[package]] name = "relative-path" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e898588f33fdd5b9420719948f9f2a32c922a246964576f71ba7f24f80610fbc" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "rstest" -version = "0.18.2" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +checksum = "9d5316d2a1479eeef1ea21e7f9ddc67c191d497abc8fc3ba2467857abbb68330" dependencies = [ "futures", "futures-timer", @@ -1304,9 +1350,9 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.18.2" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +checksum = "04a9df72cc1f67020b0d63ad9bfe4a323e459ea7eb68e03bd9824db49f9a4c25" dependencies = [ "cfg-if 1.0.0", "glob", @@ -1321,9 +1367,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" @@ -1342,9 +1388,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.32" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.5.0", "errno", @@ -1365,15 +1411,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" +checksum = "092474d1a01ea8278f69e6a358998405fae5b8b963ddaeb2b0b04a128bf1dfb0" [[package]] name = "ryu" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "same-file" @@ -1392,24 +1438,24 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.201" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.201" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865" dependencies = [ "proc-macro2", "quote", @@ -1418,9 +1464,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.115" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" dependencies = [ "itoa", "ryu", @@ -1435,9 +1481,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] @@ -1472,11 +1518,32 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "snafu" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75976f4748ab44f6e5332102be424e7c2dc18daeaf7e725f2040c3ebb133512e" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b19911debfb8c2fb1107bc6cb2d61868aaf53a988449213959bb1b5b1ed95f" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" -version = "0.5.6" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys 0.52.0", @@ -1499,9 +1566,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.58" +version = "2.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" +checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704" dependencies = [ "proc-macro2", "quote", @@ -1510,18 +1577,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.58" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.58" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" dependencies = [ "proc-macro2", "quote", @@ -1530,9 +1597,9 @@ dependencies = [ [[package]] name = "thread-priority" -version = "0.16.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a617e9eeeb20448b01a8e2427fb80dfbc9c49d79a1de3b11f25731edbf547e3c" +checksum = "0d3b04d33c9633b8662b167b847c7ab521f83d1ae20f2321b65b5b925e532e36" dependencies = [ "bitflags 2.5.0", "cfg-if 1.0.0", @@ -1555,9 +1622,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.34" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", @@ -1576,9 +1643,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -1651,6 +1718,17 @@ dependencies = [ "syn", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ttf-parser" version = "0.15.2" @@ -1704,7 +1782,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "vncserver" version = "0.2.2" -source = "git+https://github.com/sbernauer/libvnc-rs.git#72bbabde0dd992d68c54e5bca3c513de9e025fb9" +source = "git+https://github.com/sbernauer/libvnc-rs.git#2bf903ad64c7e33554b011eb05f96c11eadb8821" dependencies = [ "bindgen", "cc", @@ -1827,11 +1905,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -1846,7 +1924,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -1864,7 +1942,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -1884,17 +1962,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", ] [[package]] @@ -1905,9 +1984,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" @@ -1917,9 +1996,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" @@ -1929,9 +2008,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" @@ -1941,9 +2026,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" @@ -1953,9 +2038,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" @@ -1965,9 +2050,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" @@ -1977,9 +2062,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "zune-inflate" diff --git a/Cargo.toml b/Cargo.toml index 1060811..2a13be9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,47 +1,39 @@ -[package] -name = "breakwater" -version = "0.1.0" +[workspace] +members = ["breakwater-core", "breakwater-parser", "breakwater"] +resolver = "2" + +[workspace.package] +version = "0.12.0" +license = "Beerware" +authors = ["Sebastian Bernauer "] edition = "2021" +repository = "https://github.com/sernauer/breakwater" -[dependencies] +[workspace.dependencies] chrono = "0.4" -const_format = "0.2" clap = { version = "4.5", features = ["derive"] } -rusttype = "0.9" -number_prefix = "0.4" +const_format = "0.2" +criterion = {version = "0.5", features = ["async_tokio"]} +enum_dispatch = "0.3" env_logger = "0.11" -lazy_static = "1.4" log = "0.4" +memchr = "2.7" +number_prefix = "0.4" +pixelbomber = "0.6" prometheus_exporter = "0.8" +rstest = "0.19" +rusttype = "0.9" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" simple_moving_average = "1.0" -thread-priority = "0.16" +snafu = "0.8" +thread-priority = "1.1" tokio = { version = "1.37", features = ["fs", "rt-multi-thread", "net", "io-util", "macros", "process", "signal", "sync", "time"] } -vncserver = { version ="0.2", optional = true} - -[dev-dependencies] -criterion = {version = "0.5", features = ["async_tokio"]} -pixelbomber = "0.6" -rstest = "0.18" -rand = "0.8" - -[features] -default = ["vnc"] -vnc = ["dep:vncserver"] -alpha = [] - -[lib] -name = "breakwater" -path = "src/lib.rs" - -[[bin]] -name = "breakwater" -path = "src/main.rs" +trait-variant = "0.1" +vncserver = "0.2" -[[bench]] -name = "benchmarks" -harness = false +breakwater-core = { path = "breakwater-core" } +breakwater-parser = { path = "breakwater-parser" } [profile.dev] opt-level = 3 @@ -54,4 +46,5 @@ codegen-units = 1 [patch.crates-io] # https://github.com/rayylee/libvnc-rs/pull/2: Update bindgen to 0.69 +# Also https://github.com/sbernauer/libvnc-rs/commit/2bf903ad64c7e33554b011eb05f96c11eadb8821 to get breakwater to build on NixOS vncserver = { git = 'https://github.com/sbernauer/libvnc-rs.git' } diff --git a/Dockerfile b/Dockerfile index 656412f..514559c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,28 @@ -FROM rust:1.70.0-bookworm as builder +FROM rust:1.78.0-bookworm as builder WORKDIR /breakwater -COPY src/ src/ +COPY breakwater-core/ breakwater-core/ +COPY breakwater-parser/ breakwater-parser/ +COPY breakwater/ breakwater/ COPY Cargo.toml . +COPY Cargo.lock . +COPY rust-toolchain.toml . COPY Arial.ttf . RUN apt-get update && \ apt-get install -y clang libvncserver-dev && \ rm -rf /var/lib/apt/lists/* -RUN rustup toolchain install nightly + # We don't want to e.g. set "-C target-cpu=native", so that the binary should run everywhere -RUN RUSTFLAGS='' cargo +nightly install --path . +# Also we can always build with vnc server support as the docker image contains all needed dependencies in any case +RUN RUSTFLAGS='' cargo build --release --features vnc + -FROM debian:bookworm-slim +FROM debian:bookworm-slim as final RUN apt-get update && \ apt-get install -y libvncserver1 ffmpeg && \ rm -rf /var/lib/apt/lists/* -COPY --from=builder /usr/local/cargo/bin/breakwater /usr/local/bin/breakwater + +COPY --from=builder /breakwater/target/release/breakwater /usr/local/bin/breakwater ENTRYPOINT ["breakwater"] diff --git a/README.md b/README.md index 203bbfd..1253125 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Commands must be sent newline-separated, for more details see [Pixelflut](https: # Usage The easiest way is to continue with the provided [Ready to use Docker setup](#run-in-docker-container) below. -If you prefer the manual way (the best performance) you need to have [Rust installed](https://www.rust-lang.org/tools/install). +If you prefer the manual way (the best performance - as e.g. can use native SIMD instructions) you need to have [Rust installed](https://www.rust-lang.org/tools/install). You may need to install some additional packages with `sudo apt install pkg-config libvncserver-dev` Then you can directly run the server with ```bash diff --git a/breakwater-core/Cargo.toml b/breakwater-core/Cargo.toml new file mode 100644 index 0000000..0d73d3f --- /dev/null +++ b/breakwater-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "breakwater-core" +description = "Core structs" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +const_format.workspace = true +tokio.workspace = true + +[features] +alpha = [] diff --git a/src/framebuffer.rs b/breakwater-core/src/framebuffer.rs similarity index 50% rename from src/framebuffer.rs rename to breakwater-core/src/framebuffer.rs index 80704bf..61f7eda 100644 --- a/src/framebuffer.rs +++ b/breakwater-core/src/framebuffer.rs @@ -1,14 +1,11 @@ -use std::{cell::UnsafeCell, slice}; +use std::slice; pub struct FrameBuffer { width: usize, height: usize, - buffer: UnsafeCell>, + buffer: Vec, } -// FIXME Nothing to see here, I don't know what I'm doing ¯\_(ツ)_/¯ -unsafe impl Sync for FrameBuffer {} - impl FrameBuffer { pub fn new(width: usize, height: usize) -> Self { let mut buffer = Vec::with_capacity(width * height); @@ -16,7 +13,7 @@ impl FrameBuffer { FrameBuffer { width, height, - buffer: UnsafeCell::from(buffer), + buffer, } } @@ -35,7 +32,7 @@ impl FrameBuffer { #[inline(always)] pub fn get(&self, x: usize, y: usize) -> Option { if x < self.width && y < self.height { - unsafe { Some((*self.buffer.get())[x + y * self.width]) } + Some(*unsafe { self.buffer.get_unchecked(x + y * self.width) }) } else { None } @@ -43,26 +40,30 @@ impl FrameBuffer { #[inline(always)] pub fn get_unchecked(&self, x: usize, y: usize) -> u32 { - unsafe { (*self.buffer.get())[x + y * self.width] } + *unsafe { self.buffer.get_unchecked(x + y * self.width) } } #[inline(always)] pub fn set(&self, x: usize, y: usize, rgba: u32) { - // TODO: If we make the FrameBuffer large enough (e.g. 10_000 x 10_000) we don't need to check the bounds here (x and y are max 4 digit numbers). - // (flamegraph has shown 5.21% of runtime in this bound check O.o) + // https://github.com/sbernauer/breakwater/pull/11 + // If we make the FrameBuffer large enough (e.g. 10_000 x 10_000) we don't need to check the bounds here + // (x and y are max 4 digit numbers). Flamegraph has shown 5.21% of runtime in this bound check. On the other + // hand this can increase the framebuffer size dramatically and lowers the cash locality. + // In the end we did *not* go with this change. if x < self.width && y < self.height { - unsafe { (*self.buffer.get())[x + y * self.width] = rgba } + unsafe { + let ptr = self.buffer.as_ptr().add(x + y * self.width) as *mut u32; + *ptr = rgba; + } } } - pub fn get_buffer(&self) -> *mut Vec { - self.buffer.get() + pub fn get_buffer(&self) -> &[u32] { + &self.buffer } pub fn as_bytes(&self) -> &[u8] { - let buffer = self.buffer.get(); - let len_in_bytes: usize = unsafe { (*buffer).len() } * 4; - - unsafe { slice::from_raw_parts((*buffer).as_ptr() as *const u8, len_in_bytes) } + let len_in_bytes = self.buffer.len() * 4; + unsafe { slice::from_raw_parts(self.buffer.as_ptr() as *const u8, len_in_bytes) } } } diff --git a/breakwater-core/src/lib.rs b/breakwater-core/src/lib.rs new file mode 100644 index 0000000..3e75fe9 --- /dev/null +++ b/breakwater-core/src/lib.rs @@ -0,0 +1,22 @@ +use const_format::formatcp; + +pub mod framebuffer; +pub mod test_helpers; + +pub const HELP_TEXT: &[u8] = formatcp!("\ +Pixelflut server powered by breakwater https://github.com/sbernauer/breakwater +Available commands: +HELP: Show this help +PX x y rrggbb: Color the pixel (x,y) with the given hexadecimal color rrggbb +{} +PX x y gg: Color the pixel (x,y) with the hexadecimal color gggggg. Basically this is the same as the other commands, but is a more efficient way of filling white, black or gray areas +PX x y: Get the color value of the pixel (x,y) +SIZE: Get the size of the drawing surface, e.g. `SIZE 1920 1080` +OFFSET x y: Apply offset (x,y) to all further pixel draws on this connection. This can e.g. be used to pre-calculate an image/animation and simply use the OFFSET command to move it around the screen without the need to re-calculate it +", +if cfg!(feature = "alpha") { + "PX x y rrggbbaa: Color the pixel (x,y) with the given hexadecimal color rrggbb and a transparency of aa, where ff means draw normally on top of the existing pixel and 00 means fully transparent (no change at all)" +} else { + "PX x y rrggbbaa: Color the pixel (x,y) with the given hexadecimal color rrggbb. The alpha part is discarded for performance reasons, as breakwater was compiled without the alpha feature" +} +).as_bytes(); diff --git a/src/test/helpers/dev_null_tcp_stream.rs b/breakwater-core/src/test_helpers/dev_null_tcp_stream.rs similarity index 100% rename from src/test/helpers/dev_null_tcp_stream.rs rename to breakwater-core/src/test_helpers/dev_null_tcp_stream.rs diff --git a/src/test/helpers/mock_tcp_stream.rs b/breakwater-core/src/test_helpers/mock_tcp_stream.rs similarity index 100% rename from src/test/helpers/mock_tcp_stream.rs rename to breakwater-core/src/test_helpers/mock_tcp_stream.rs diff --git a/src/test/helpers/mod.rs b/breakwater-core/src/test_helpers/mod.rs similarity index 66% rename from src/test/helpers/mod.rs rename to breakwater-core/src/test_helpers/mod.rs index 8f68a04..78968bc 100644 --- a/src/test/helpers/mod.rs +++ b/breakwater-core/src/test_helpers/mod.rs @@ -1,7 +1,5 @@ -mod dev_null_tcp_stream; -mod mock_tcp_stream; -mod pixelflut_commands; - pub use dev_null_tcp_stream::*; pub use mock_tcp_stream::*; -pub use pixelflut_commands::*; + +mod dev_null_tcp_stream; +mod mock_tcp_stream; diff --git a/breakwater-parser/Cargo.toml b/breakwater-parser/Cargo.toml new file mode 100644 index 0000000..9277730 --- /dev/null +++ b/breakwater-parser/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "breakwater-parser" +description = "Parses Pixelflut commands as fast as possible" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[[bench]] +name = "parsing" +harness = false + +[dependencies] +breakwater-core.workspace = true + +enum_dispatch.workspace = true +memchr.workspace = true +snafu.workspace = true +tokio.workspace = true +trait-variant.workspace = true + +[dev-dependencies] +criterion.workspace = true +pixelbomber.workspace = true + +[features] +alpha = [] diff --git a/breakwater-parser/benches/mixed.png b/breakwater-parser/benches/mixed.png new file mode 100644 index 0000000..0cdb00f Binary files /dev/null and b/breakwater-parser/benches/mixed.png differ diff --git a/breakwater-parser/benches/non-transparent.png b/breakwater-parser/benches/non-transparent.png new file mode 100644 index 0000000..5061f33 Binary files /dev/null and b/breakwater-parser/benches/non-transparent.png differ diff --git a/breakwater-parser/benches/parsing.rs b/breakwater-parser/benches/parsing.rs new file mode 100644 index 0000000..22153a7 --- /dev/null +++ b/breakwater-parser/benches/parsing.rs @@ -0,0 +1,112 @@ +use std::{sync::Arc, time::Duration}; + +use breakwater_core::{framebuffer::FrameBuffer, test_helpers::DevNullTcpStream}; +#[cfg(target_arch = "x86_64")] +use breakwater_parser::assembler::AssemblerParser; +use breakwater_parser::{ + memchr::MemchrParser, original::OriginalParser, refactored::RefactoredParser, Parser, + ParserImplementation, +}; +use criterion::{criterion_group, criterion_main, Criterion}; +use pixelbomber::image_handler::{self, ImageConfigBuilder}; + +const FRAMEBUFFER_WIDTH: usize = 1920; +const FRAMEBUFFER_HEIGHT: usize = 1080; + +fn compare_implementations(c: &mut Criterion) { + invoke_benchmark( + c, + "parse_draw_commands_ordered", + "benches/non-transparent.png", + false, + false, + false, + ); + invoke_benchmark( + c, + "parse_draw_commands_unordered", + "benches/non-transparent.png", + true, + false, + false, + ); + invoke_benchmark( + c, + "parse_draw_commands_with_offset", + "benches/non-transparent.png", + true, + true, + false, + ); + invoke_benchmark( + c, + "parse_mixed_draw_commands", + "benches/mixed.png", + false, + false, + true, + ); +} + +fn invoke_benchmark( + c: &mut Criterion, + bench_name: &str, + image: &str, + shuffle: bool, + use_offset: bool, + use_gray: bool, +) { + let commands = image_handler::load( + vec![image], + ImageConfigBuilder::new() + .width(FRAMEBUFFER_WIDTH as u32) + .height(FRAMEBUFFER_HEIGHT as u32) + .shuffle(shuffle) + .offset_usage(use_offset) + .gray_usage(use_gray) + .build(), + ) + .pop() + .expect("Fail to retrieve Pixelflut commands"); + + let mut c_group = c.benchmark_group(bench_name); + + let fb = Arc::new(FrameBuffer::new(FRAMEBUFFER_WIDTH, FRAMEBUFFER_HEIGHT)); + + #[cfg(target_arch = "x86_64")] + let parser_names = ["original", "refactored", "memchr", "assembler"]; + #[cfg(not(target_arch = "x86_64"))] + let parser_names = ["original", "refactored", "memchr"]; + + for parse_name in parser_names { + c_group.bench_with_input(parse_name, &commands, |b, input| { + b.to_async(tokio::runtime::Runtime::new().expect("Failed to start tokio runtime")) + .iter(|| async { + let mut parser = match parse_name { + "original" => { + ParserImplementation::Original(OriginalParser::new(fb.clone())) + } + "refactored" => { + ParserImplementation::Refactored(RefactoredParser::new(fb.clone())) + } + "memchr" => ParserImplementation::Naive(MemchrParser::new(fb.clone())), + #[cfg(target_arch = "x86_64")] + "assembler" => ParserImplementation::Assembler(AssemblerParser::default()), + _ => panic!("Parser implementation {parse_name} not known"), + }; + + parser + .parse(input, DevNullTcpStream::default()) + .await + .expect("Failed to parse commands"); + }); + }); + } +} + +criterion_group!( + name = parsing; + config = Criterion::default().warm_up_time(Duration::from_secs(1)).measurement_time(Duration::from_secs(3)); + targets = compare_implementations +); +criterion_main!(parsing); diff --git a/breakwater-parser/src/assembler.rs b/breakwater-parser/src/assembler.rs new file mode 100644 index 0000000..eb1484c --- /dev/null +++ b/breakwater-parser/src/assembler.rs @@ -0,0 +1,42 @@ +use std::arch::asm; + +use tokio::io::AsyncWriteExt; + +use crate::{Parser, ParserError}; + +const PARSER_LOOKAHEAD: usize = "PX 1234 1234 rrggbbaa\n".len(); // Longest possible command + +#[derive(Default)] +pub struct AssemblerParser {} + +impl Parser for AssemblerParser { + async fn parse( + &mut self, + buffer: &[u8], + _stream: impl AsyncWriteExt + Send + Unpin, + ) -> Result { + let mut last_byte_parsed = 0; + + // This loop does nothing and should be seen as a placeholder + unsafe { + asm!( + "mov {i}, {buffer_start}", + "2:", + "inc {last_byte_parsed}", + "inc {i}", + "cmp {i}, {buffer_end}", + "jl 2b", + buffer_start = in(reg) buffer.as_ptr(), + buffer_end = in(reg) buffer.as_ptr().add(buffer.len()), + last_byte_parsed = inout(reg) last_byte_parsed, + i = out(reg) _, + ) + } + + Ok(last_byte_parsed) + } + + fn parser_lookahead(&self) -> usize { + PARSER_LOOKAHEAD + } +} diff --git a/breakwater-parser/src/lib.rs b/breakwater-parser/src/lib.rs new file mode 100644 index 0000000..797def6 --- /dev/null +++ b/breakwater-parser/src/lib.rs @@ -0,0 +1,41 @@ +// Needed for simple implementation +#![feature(portable_simd)] + +use enum_dispatch::enum_dispatch; +use snafu::Snafu; +use tokio::io::AsyncWriteExt; + +#[cfg(target_arch = "x86_64")] +pub mod assembler; +pub mod memchr; +pub mod original; +pub mod refactored; + +#[derive(Debug, Snafu)] +pub enum ParserError { + #[snafu(display("Failed to write to TCP socket"))] + WriteToTcpSocket { source: std::io::Error }, +} + +#[enum_dispatch(ParserImplementation)] +// According to https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html +#[trait_variant::make(SendParser: Send)] +pub trait Parser { + async fn parse( + &mut self, + buffer: &[u8], + stream: impl AsyncWriteExt + Send + Unpin, + ) -> Result; + + // Sadly this cant be const (yet?) (https://github.com/rust-lang/rust/issues/71971 and https://github.com/rust-lang/rfcs/pull/2632) + fn parser_lookahead(&self) -> usize; +} + +#[enum_dispatch] +pub enum ParserImplementation { + Original(original::OriginalParser), + Refactored(refactored::RefactoredParser), + Naive(memchr::MemchrParser), + #[cfg(target_arch = "x86_64")] + Assembler(assembler::AssemblerParser), +} diff --git a/breakwater-parser/src/memchr.rs b/breakwater-parser/src/memchr.rs new file mode 100644 index 0000000..8cb8a7c --- /dev/null +++ b/breakwater-parser/src/memchr.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use breakwater_core::framebuffer::FrameBuffer; +use tokio::io::AsyncWriteExt; + +use crate::{Parser, ParserError}; + +pub struct MemchrParser { + fb: Arc, +} + +impl MemchrParser { + pub fn new(fb: Arc) -> Self { + Self { fb } + } +} + +impl Parser for MemchrParser { + async fn parse( + &mut self, + buffer: &[u8], + _stream: impl AsyncWriteExt + Send + Unpin, + ) -> Result { + let mut last_char_after_newline = 0; + for newline in memchr::memchr_iter(b'\n', buffer) { + // TODO Use get_unchecked everywhere + let line = &buffer[last_char_after_newline..newline.saturating_sub(1)]; + last_char_after_newline = newline + 1; + + if line.is_empty() { + panic!("Line is empty, we probably should handle this"); + } + + let mut spaces = memchr::memchr_iter(b' ', line); + let Some(first_space) = spaces.next() else { + continue; + }; + + match &line[0..first_space] { + b"PX" => { + let Some(second_space) = spaces.next() else { + continue; + }; + let Some(third_space) = spaces.next() else { + continue; + }; + let Some(fourth_space) = spaces.next() else { + continue; + }; + let x: u16 = std::str::from_utf8(&line[first_space + 1..second_space]) + .expect("Not utf-8") + .parse() + .expect("x was not a number"); + let y: u16 = std::str::from_utf8(&line[second_space + 1..third_space]) + .expect("Not utf-8") + .parse() + .expect("y was not a number"); + let rgba: u32 = std::str::from_utf8(&line[third_space + 1..fourth_space]) + .expect("Not utf-8") + .parse() + .expect("rgba was not a number"); + + self.fb.set(x as usize, y as usize, rgba); + } + _ => { + continue; + } + } + } + + Ok(last_char_after_newline.saturating_sub(1)) + } + + fn parser_lookahead(&self) -> usize { + 0 + } +} diff --git a/breakwater-parser/src/original.rs b/breakwater-parser/src/original.rs new file mode 100644 index 0000000..993043c --- /dev/null +++ b/breakwater-parser/src/original.rs @@ -0,0 +1,271 @@ +use std::{ + simd::{num::SimdUint, u32x8, Simd}, + sync::Arc, +}; + +use breakwater_core::{framebuffer::FrameBuffer, HELP_TEXT}; +use tokio::io::AsyncWriteExt; + +use crate::{Parser, ParserError}; + +pub const PARSER_LOOKAHEAD: usize = "PX 1234 1234 rrggbbaa\n".len(); // Longest possible command + +pub(crate) const PX_PATTERN: u64 = string_to_number(b"PX \0\0\0\0\0"); +pub(crate) const OFFSET_PATTERN: u64 = string_to_number(b"OFFSET \0\0"); +pub(crate) const SIZE_PATTERN: u64 = string_to_number(b"SIZE\0\0\0\0"); +pub(crate) const HELP_PATTERN: u64 = string_to_number(b"HELP\0\0\0\0"); + +pub struct OriginalParser { + connection_x_offset: usize, + connection_y_offset: usize, + fb: Arc, +} + +impl OriginalParser { + pub fn new(fb: Arc) -> Self { + Self { + connection_x_offset: 0, + connection_y_offset: 0, + fb, + } + } +} + +impl Parser for OriginalParser { + async fn parse( + &mut self, + buffer: &[u8], + mut stream: impl AsyncWriteExt + Send + Unpin, + ) -> Result { + let mut last_byte_parsed = 0; + + let mut i = 0; // We can't use a for loop here because Rust don't lets use skip characters by incrementing i + let loop_end = buffer.len().saturating_sub(PARSER_LOOKAHEAD); // Let's extract the .len() call and the subtraction into it's own variable so we only compute it once + + while i < loop_end { + let current_command = + unsafe { (buffer.as_ptr().add(i) as *const u64).read_unaligned() }; + if current_command & 0x00ff_ffff == PX_PATTERN { + i += 3; + + let (mut x, mut y, present) = parse_pixel_coordinates(buffer.as_ptr(), &mut i); + + if present { + x += self.connection_x_offset; + y += self.connection_y_offset; + + // Separator between coordinates and color + if unsafe { *buffer.get_unchecked(i) } == b' ' { + i += 1; + + // TODO: Determine what clients use more: RGB, RGBA or gg variant. + // If RGBA is used more often move the RGB code below the RGBA code + + // Must be followed by 6 bytes RGB and newline or ... + if unsafe { *buffer.get_unchecked(i + 6) } == b'\n' { + last_byte_parsed = i + 6; + i += 7; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop + + let rgba: u32 = simd_unhex(unsafe { buffer.as_ptr().add(i - 7) }); + + self.fb.set(x, y, rgba & 0x00ff_ffff); + continue; + } + + // ... or must be followed by 8 bytes RGBA and newline + #[cfg(not(feature = "alpha"))] + if unsafe { *buffer.get_unchecked(i + 8) } == b'\n' { + last_byte_parsed = i + 8; + i += 9; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop + + let rgba: u32 = simd_unhex(unsafe { buffer.as_ptr().add(i - 9) }); + + self.fb.set(x, y, rgba & 0x00ff_ffff); + continue; + } + #[cfg(feature = "alpha")] + if unsafe { *buffer.get_unchecked(i + 8) } == b'\n' { + last_byte_parsed = i + 8; + i += 9; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop + + let rgba = simd_unhex(unsafe { buffer.as_ptr().add(i - 9) }); + + let alpha = (rgba >> 24) & 0xff; + + if alpha == 0 || x >= self.fb.get_width() || y >= self.fb.get_height() { + continue; + } + + let alpha_comp = 0xff - alpha; + let current = self.fb.get_unchecked(x, y); + let r = (rgba >> 16) & 0xff; + let g = (rgba >> 8) & 0xff; + let b = rgba & 0xff; + + let r: u32 = (((current >> 24) & 0xff) * alpha_comp + r * alpha) / 0xff; + let g: u32 = (((current >> 16) & 0xff) * alpha_comp + g * alpha) / 0xff; + let b: u32 = (((current >> 8) & 0xff) * alpha_comp + b * alpha) / 0xff; + + self.fb.set(x, y, r << 16 | g << 8 | b); + continue; + } + + // ... for the efficient/lazy clients + if unsafe { *buffer.get_unchecked(i + 2) } == b'\n' { + last_byte_parsed = i + 2; + i += 3; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop + + let base = simd_unhex(unsafe { buffer.as_ptr().add(i - 3) }) & 0xff; + + let rgba: u32 = base << 16 | base << 8 | base; + + self.fb.set(x, y, rgba); + + continue; + } + } + + // End of command to read Pixel value + if unsafe { *buffer.get_unchecked(i) } == b'\n' { + last_byte_parsed = i; + i += 1; + if let Some(rgb) = self.fb.get(x, y) { + match stream + .write_all( + format!( + "PX {} {} {:06x}\n", + // We don't want to return the actual (absolute) coordinates, the client should also get the result offseted + x - self.connection_x_offset, + y - self.connection_y_offset, + rgb.to_be() >> 8 + ) + .as_bytes(), + ) + .await + { + Ok(_) => (), + Err(_) => continue, + } + } + continue; + } + } + } else if current_command & 0x00ff_ffff_ffff_ffff == OFFSET_PATTERN { + i += 7; + + let (x, y, present) = parse_pixel_coordinates(buffer.as_ptr(), &mut i); + + // End of command to set offset + if present && unsafe { *buffer.get_unchecked(i) } == b'\n' { + last_byte_parsed = i; + self.connection_x_offset = x; + self.connection_y_offset = y; + continue; + } + } else if current_command & 0xffff_ffff == SIZE_PATTERN { + i += 4; + last_byte_parsed = i - 1; + + stream + .write_all( + format!("SIZE {} {}\n", self.fb.get_width(), self.fb.get_height()) + .as_bytes(), + ) + .await + .expect("Failed to write bytes to tcp socket"); + continue; + } else if current_command & 0xffff_ffff == HELP_PATTERN { + i += 4; + last_byte_parsed = i - 1; + + stream + .write_all(HELP_TEXT) + .await + .expect("Failed to write bytes to tcp socket"); + continue; + } + + i += 1; + } + + Ok(last_byte_parsed.wrapping_sub(1)) + } + + fn parser_lookahead(&self) -> usize { + PARSER_LOOKAHEAD + } +} + +const fn string_to_number(input: &[u8]) -> u64 { + (input[7] as u64) << 56 + | (input[6] as u64) << 48 + | (input[5] as u64) << 40 + | (input[4] as u64) << 32 + | (input[3] as u64) << 24 + | (input[2] as u64) << 16 + | (input[1] as u64) << 8 + | (input[0] as u64) +} + +const SHIFT_PATTERN: Simd = u32x8::from_array([4, 0, 12, 8, 20, 16, 28, 24]); +const SIMD_6: Simd = u32x8::from_array([6; 8]); +const SIMD_F: Simd = u32x8::from_array([0xf; 8]); +const SIMD_9: Simd = u32x8::from_array([9; 8]); + +/// Parse a slice of 8 characters into a single u32 number +/// is undefined behavior for invalid characters +#[inline(always)] +pub(crate) fn simd_unhex(value: *const u8) -> u32 { + // Feel free to find a better, but fast, way, to cast all integers as u32 + let input = unsafe { + u32x8::from_array([ + *value as u32, + *value.add(1) as u32, + *value.add(2) as u32, + *value.add(3) as u32, + *value.add(4) as u32, + *value.add(5) as u32, + *value.add(6) as u32, + *value.add(7) as u32, + ]) + }; + // Heavily inspired by https://github.com/nervosnetwork/faster-hex/blob/a4c06b387ddeeea311c9e84a3adcaf01015cf40e/src/decode.rs#L80 + let sr6 = input >> SIMD_6; + let and15 = input & SIMD_F; + let mul = sr6 * SIMD_9; + let hexed = and15 + mul; + let shifted = hexed << SHIFT_PATTERN; + shifted.reduce_or() +} + +#[inline(always)] +fn parse_coordinate(buffer: *const u8, current_index: &mut usize) -> (usize, bool) { + let digits = unsafe { (buffer.add(*current_index) as *const usize).read_unaligned() }; + + let mut result = 0; + let mut visited = false; + // The compiler will unroll this loop, but this way, it is more maintainable + for pos in 0..4 { + let digit = (digits >> (pos * 8)) & 0xff; + if digit >= b'0' as usize && digit <= b'9' as usize { + result = 10 * result + digit - b'0' as usize; + *current_index += 1; + visited = true; + } else { + break; + } + } + + (result, visited) +} + +#[inline(always)] +pub(crate) fn parse_pixel_coordinates( + buffer: *const u8, + current_index: &mut usize, +) -> (usize, usize, bool) { + let (x, x_visited) = parse_coordinate(buffer, current_index); + *current_index += 1; + let (y, y_visited) = parse_coordinate(buffer, current_index); + (x, y, x_visited && y_visited) +} diff --git a/breakwater-parser/src/refactored.rs b/breakwater-parser/src/refactored.rs new file mode 100644 index 0000000..ced033c --- /dev/null +++ b/breakwater-parser/src/refactored.rs @@ -0,0 +1,240 @@ +use std::sync::Arc; + +use breakwater_core::{framebuffer::FrameBuffer, HELP_TEXT}; +use snafu::ResultExt; +use tokio::io::AsyncWriteExt; + +use crate::{ + original::{ + parse_pixel_coordinates, simd_unhex, HELP_PATTERN, OFFSET_PATTERN, PX_PATTERN, SIZE_PATTERN, + }, + Parser, ParserError, +}; + +const PARSER_LOOKAHEAD: usize = "PX 1234 1234 rrggbbaa\n".len(); // Longest possible command + +pub struct RefactoredParser { + connection_x_offset: usize, + connection_y_offset: usize, + fb: Arc, +} + +impl RefactoredParser { + pub fn new(fb: Arc) -> Self { + Self { + connection_x_offset: 0, + connection_y_offset: 0, + fb, + } + } + + #[inline(always)] + async fn handle_pixel( + &self, + buffer: &[u8], + mut idx: usize, + stream: &mut (impl AsyncWriteExt + Send + Unpin), + ) -> Result<(usize, usize), ParserError> { + let previous = idx; + idx += 3; + + let (mut x, mut y, present) = parse_pixel_coordinates(buffer.as_ptr(), &mut idx); + + if present { + x += self.connection_x_offset; + y += self.connection_y_offset; + + // Separator between coordinates and color + if unsafe { *buffer.get_unchecked(idx) } == b' ' { + idx += 1; + + // TODO: Determine what clients use more: RGB, RGBA or gg variant. + // If RGBA is used more often move the RGB code below the RGBA code + + // Must be followed by 6 bytes RGB and newline or ... + if unsafe { *buffer.get_unchecked(idx + 6) } == b'\n' { + idx += 7; + self.handle_rgb(idx, buffer, x, y); + Ok((idx, idx)) + } + // ... or must be followed by 8 bytes RGBA and newline + else if unsafe { *buffer.get_unchecked(idx + 8) } == b'\n' { + idx += 9; + self.handle_rgba(idx, buffer, x, y); + Ok((idx, idx)) + } + // ... for the efficient/lazy clients + else if unsafe { *buffer.get_unchecked(idx + 2) } == b'\n' { + idx += 3; + self.handle_gray(idx, buffer, x, y); + Ok((idx, idx)) + } else { + Ok((idx, previous)) + } + } + // End of command to read Pixel value + else if unsafe { *buffer.get_unchecked(idx) } == b'\n' { + idx += 1; + self.handle_get_pixel(stream, x, y).await?; + Ok((idx, idx)) + } else { + Ok((idx, previous)) + } + } else { + Ok((idx, previous)) + } + } + + #[inline(always)] + fn handle_offset(&mut self, idx: &mut usize, buffer: &[u8]) { + let (x, y, present) = parse_pixel_coordinates(buffer.as_ptr(), idx); + + // End of command to set offset + if present && unsafe { *buffer.get_unchecked(*idx) } == b'\n' { + self.connection_x_offset = x; + self.connection_y_offset = y; + } + } + + #[inline(always)] + async fn handle_size( + &self, + stream: &mut (impl AsyncWriteExt + Send + Unpin), + ) -> Result<(), ParserError> { + stream + .write_all( + format!("SIZE {} {}\n", self.fb.get_width(), self.fb.get_height()).as_bytes(), + ) + .await + .context(crate::WriteToTcpSocketSnafu)?; + Ok(()) + } + + #[inline(always)] + async fn handle_help( + &self, + stream: &mut (impl AsyncWriteExt + Send + Unpin), + ) -> Result<(), ParserError> { + stream + .write_all(HELP_TEXT) + .await + .context(crate::WriteToTcpSocketSnafu)?; + Ok(()) + } + + #[inline(always)] + fn handle_rgb(&self, idx: usize, buffer: &[u8], x: usize, y: usize) { + let rgba: u32 = simd_unhex(unsafe { buffer.as_ptr().add(idx - 7) }); + + self.fb.set(x, y, rgba & 0x00ff_ffff); + } + + #[cfg(not(feature = "alpha"))] + #[inline(always)] + fn handle_rgba(&self, idx: usize, buffer: &[u8], x: usize, y: usize) { + let rgba: u32 = simd_unhex(unsafe { buffer.as_ptr().add(idx - 9) }); + + self.fb.set(x, y, rgba & 0x00ff_ffff); + } + + #[cfg(feature = "alpha")] + #[inline(always)] + fn handle_rgba(&self, idx: usize, buffer: &[u8], x: usize, y: usize) { + let rgba: u32 = simd_unhex(unsafe { buffer.as_ptr().add(idx - 9) }); + + let alpha = (rgba >> 24) & 0xff; + + if alpha == 0 || x >= self.fb.get_width() || y >= self.fb.get_height() { + return; + } + + let alpha_comp = 0xff - alpha; + let current = self.fb.get_unchecked(x, y); + let r = (rgba >> 16) & 0xff; + let g = (rgba >> 8) & 0xff; + let b = rgba & 0xff; + + let r: u32 = (((current >> 24) & 0xff) * alpha_comp + r * alpha) / 0xff; + let g: u32 = (((current >> 16) & 0xff) * alpha_comp + g * alpha) / 0xff; + let b: u32 = (((current >> 8) & 0xff) * alpha_comp + b * alpha) / 0xff; + + self.fb.set(x, y, r << 16 | g << 8 | b); + } + + #[inline(always)] + fn handle_gray(&self, idx: usize, buffer: &[u8], x: usize, y: usize) { + // FIXME: Read that two bytes directly instead of going through the whole SIMD vector setup. + // Or - as an alternative - still do the SIMD part but only load two bytes. + let base: u32 = simd_unhex(unsafe { buffer.as_ptr().add(idx - 3) }) & 0xff; + + let rgba: u32 = base << 16 | base << 8 | base; + + self.fb.set(x, y, rgba); + } + + #[inline(always)] + async fn handle_get_pixel( + &self, + stream: &mut (impl AsyncWriteExt + Send + Unpin), + x: usize, + y: usize, + ) -> Result<(), ParserError> { + if let Some(rgb) = self.fb.get(x, y) { + stream + .write_all( + format!( + "PX {} {} {:06x}\n", + // We don't want to return the actual (absolute) coordinates, the client should also get the result offseted + x - self.connection_x_offset, + y - self.connection_y_offset, + rgb.to_be() >> 8 + ) + .as_bytes(), + ) + .await + .context(crate::WriteToTcpSocketSnafu)?; + } + Ok(()) + } +} + +impl Parser for RefactoredParser { + async fn parse( + &mut self, + buffer: &[u8], + mut stream: impl AsyncWriteExt + Send + Unpin, + ) -> Result { + let mut last_byte_parsed = 0; + + let mut i = 0; // We can't use a for loop here because Rust don't lets use skip characters by incrementing i + let loop_end = buffer.len().saturating_sub(PARSER_LOOKAHEAD); // Let's extract the .len() call and the subtraction into it's own variable so we only compute it once + + while i < loop_end { + let current_command = + unsafe { (buffer.as_ptr().add(i) as *const u64).read_unaligned() }; + if current_command & 0x00ff_ffff == PX_PATTERN { + (i, last_byte_parsed) = self.handle_pixel(buffer, i, &mut stream).await?; + } else if current_command & 0x00ff_ffff_ffff_ffff == OFFSET_PATTERN { + i += 7; + self.handle_offset(&mut i, buffer); + last_byte_parsed = i; + } else if current_command & 0xffff_ffff == SIZE_PATTERN { + i += 4; + last_byte_parsed = i; + self.handle_size(&mut stream).await?; + } else if current_command & 0xffff_ffff == HELP_PATTERN { + i += 4; + last_byte_parsed = i; + self.handle_help(&mut stream).await?; + } else { + i += 1; + } + } + + Ok(last_byte_parsed.wrapping_sub(1)) + } + + fn parser_lookahead(&self) -> usize { + PARSER_LOOKAHEAD + } +} diff --git a/breakwater/Cargo.toml b/breakwater/Cargo.toml new file mode 100644 index 0000000..22a6c86 --- /dev/null +++ b/breakwater/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "breakwater" +description = "Pixelflut server" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true + +[[bin]] +name = "breakwater" +path = "src/main.rs" + +[dependencies] +breakwater-core.workspace = true +breakwater-parser.workspace = true + +chrono.workspace = true +clap.workspace = true +const_format.workspace = true +env_logger.workspace = true +log.workspace = true +number_prefix.workspace = true +prometheus_exporter.workspace = true +rusttype.workspace = true +serde_json.workspace = true +serde.workspace = true +simple_moving_average.workspace = true +snafu.workspace = true +thread-priority.workspace = true +tokio.workspace = true +vncserver = { workspace = true, optional = true } + +[dev-dependencies] +rstest.workspace = true + +[features] +default = ["vnc"] +vnc = ["dep:vncserver"] +alpha = [] diff --git a/src/args.rs b/breakwater/src/cli_args.rs similarity index 82% rename from src/args.rs rename to breakwater/src/cli_args.rs index bda0816..f3a8ed0 100644 --- a/src/args.rs +++ b/breakwater/src/cli_args.rs @@ -1,8 +1,12 @@ use clap::Parser; +use const_format::formatcp; + +pub const DEFAULT_NETWORK_BUFFER_SIZE: usize = 256 * 1024; +pub const DEFAULT_NETWORK_BUFFER_SIZE_STR: &str = formatcp!("{}", DEFAULT_NETWORK_BUFFER_SIZE); #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] -pub struct Args { +pub struct CliArgs { /// Listen address to bind to. /// The default value will listen on all interfaces for IPv4 and IPv6 packets. #[clap(short, long, default_value = "[::]:1234")] @@ -20,6 +24,11 @@ pub struct Args { #[clap(short, long, default_value_t = 30)] pub fps: u32, + /// The size in bytes of the network buffer used for each open TCP connection. + /// Please use at least 64 KB (64_000 bytes). + #[clap(long, default_value = DEFAULT_NETWORK_BUFFER_SIZE_STR, value_parser = 64_000..100_000_000)] + pub network_buffer_size: i64, + /// Text to display on the screen. /// The text will be followed by "on ". #[clap(short, long, default_value = "Pixelflut server (breakwater)")] @@ -58,8 +67,7 @@ pub struct Args { pub video_save_folder: Option, /// Port of the VNC server. - // #[cfg_attr(feature = "vnc", clap(short, long, default_value_t = 5900))] #[cfg(feature = "vnc")] #[clap(short, long, default_value_t = 5900)] - pub vnc_port: u32, + pub vnc_port: u16, } diff --git a/breakwater/src/main.rs b/breakwater/src/main.rs new file mode 100644 index 0000000..2071289 --- /dev/null +++ b/breakwater/src/main.rs @@ -0,0 +1,224 @@ +use std::{env, num::TryFromIntError, sync::Arc}; + +use breakwater_core::framebuffer::FrameBuffer; +use clap::Parser; +use log::{info, trace}; +use prometheus_exporter::PrometheusExporter; +use sinks::ffmpeg::FfmpegSink; +use snafu::{ResultExt, Snafu}; +use tokio::sync::{broadcast, mpsc, oneshot}; + +use crate::{ + cli_args::CliArgs, + server::Server, + statistics::{Statistics, StatisticsEvent, StatisticsInformationEvent, StatisticsSaveMode}, +}; + +#[cfg(feature = "vnc")] +use { + crate::sinks::vnc::{self, VncServer}, + log::warn, + thread_priority::{ThreadBuilderExt, ThreadPriority}, +}; + +mod cli_args; +mod prometheus_exporter; +mod server; +mod sinks; +mod statistics; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to start Pixelflut server"))] + StartPixelflutServer { source: server::Error }, + + #[snafu(display("Failed to wait for CTRL + C signal"))] + WaitForCtrlCSignal { source: std::io::Error }, + + #[snafu(display("Failed to start Prometheus exporter"))] + StartPrometheusExporter { source: prometheus_exporter::Error }, + + #[snafu(display("Invalid network buffer size {network_buffer_size:?}"))] + InvalidNetworkBufferSize { + source: TryFromIntError, + network_buffer_size: i64, + }, + + #[snafu(display("ffmpeg dump thread error"))] + FfmpegDumpThread { source: sinks::ffmpeg::Error }, + + #[snafu(display("Failed to send ffmpg dump thread termination signal"))] + SendFfmpegDumpTerminationSignal {}, + + #[snafu(display("Failed to join ffmpg dump thread"))] + JoinFfmpegDumpThread { source: tokio::task::JoinError }, + + #[cfg(feature = "vnc")] + #[snafu(display("Failed to spawn VNC server thread"))] + SpawnVncServerThread { source: std::io::Error }, + + #[cfg(feature = "vnc")] + #[snafu(display("Failed to send VNC server shutdown signal"))] + SendVncServerShutdownSignal {}, + + #[cfg(feature = "vnc")] + #[snafu(display("Failed to stop VNC server thread"))] + StopVncServerThread {}, + + #[cfg(feature = "vnc")] + #[snafu(display("Failed to start VNC server"))] + StartVncServer { source: vnc::Error }, + + #[cfg(feature = "vnc")] + #[snafu(display("Failed to get cross-platform ThreadPriority. Please report this error message together with your operating system: {message}"))] + GetThreadPriority { message: String }, +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + if env::var("RUST_LOG").is_err() { + env::set_var("RUST_LOG", "info") + } + env_logger::init(); + + let args = CliArgs::parse(); + + let fb = Arc::new(FrameBuffer::new(args.width, args.height)); + + // If we make the channel to big, stats will start to lag behind + // TODO: Check performance impact in real-world scenario. Maybe the statistics thread blocks the other threads + let (statistics_tx, statistics_rx) = mpsc::channel::(100); + let (statistics_information_tx, statistics_information_rx_for_prometheus_exporter) = + broadcast::channel::(2); + let (ffmpeg_terminate_signal_tx, ffmpeg_terminate_signal_rx) = oneshot::channel(); + + #[cfg(feature = "vnc")] + let (vnc_terminate_signal_tx, vnc_terminate_signal_rx) = oneshot::channel(); + #[cfg(feature = "vnc")] + let statistics_information_rx_for_vnc_server = statistics_information_tx.subscribe(); + + let statistics_save_mode = if args.disable_statistics_save_file { + StatisticsSaveMode::Disabled + } else { + StatisticsSaveMode::Enabled { + save_file: args.statistics_save_file.clone(), + interval_s: args.statistics_save_interval_s, + } + }; + let mut statistics = Statistics::new( + statistics_rx, + statistics_information_tx, + statistics_save_mode, + ); + + let server = Server::new( + &args.listen_address, + Arc::clone(&fb), + statistics_tx.clone(), + args.network_buffer_size + .try_into() + // This should never happen as clap checks the range for us + .context(InvalidNetworkBufferSizeSnafu { + network_buffer_size: args.network_buffer_size, + })?, + ) + .await + .context(StartPixelflutServerSnafu)?; + let mut prometheus_exporter = PrometheusExporter::new( + &args.prometheus_listen_address, + statistics_information_rx_for_prometheus_exporter, + ) + .context(StartPrometheusExporterSnafu)?; + + let server_listener_thread = tokio::spawn(async move { server.start().await }); + let statistics_thread = tokio::spawn(async move { statistics.start().await }); + let prometheus_exporter_thread = tokio::spawn(async move { prometheus_exporter.run().await }); + + let ffmpeg_sink = FfmpegSink::new(&args, Arc::clone(&fb)); + let ffmpeg_thread = ffmpeg_sink.map(|sink| { + tokio::spawn(async move { + sink.run(ffmpeg_terminate_signal_rx) + .await + .context(FfmpegDumpThreadSnafu)?; + Ok::<(), Error>(()) + }) + }); + + #[cfg(feature = "vnc")] + let vnc_server_thread = { + let fb_for_vnc_server = Arc::clone(&fb); + let mut vnc_server = VncServer::new( + fb_for_vnc_server, + args.vnc_port, + args.fps, + statistics_tx, + statistics_information_rx_for_vnc_server, + vnc_terminate_signal_rx, + args.text, + args.font, + ) + .context(StartVncServerSnafu)?; + + // TODO Use tokio::spawn instead of std::thread::spawn + // I was not able to get to work with async closure + // We than also need to think about setting a priority + std::thread::Builder::new() + .name("breakwater vnc server thread".to_owned()) + .spawn_with_priority( + ThreadPriority::Crossplatform(70.try_into().map_err(|err: &str| { + Error::GetThreadPriority { + message: err.to_string(), + } + })?), + move |_| vnc_server.run().context(StartVncServerSnafu), + ) + } + .context(SpawnVncServerThreadSnafu)?; + + tokio::signal::ctrl_c() + .await + .context(WaitForCtrlCSignalSnafu)?; + + prometheus_exporter_thread.abort(); + server_listener_thread.abort(); + + let ffmpeg_thread_present = ffmpeg_thread.is_some(); + if let Some(ffmpeg_thread) = ffmpeg_thread { + ffmpeg_terminate_signal_tx + .send(()) + .map_err(|_| Error::SendFfmpegDumpTerminationSignal {})?; + + trace!("Waiting for thread dumping data into ffmpeg to terminate"); + ffmpeg_thread.await.context(JoinFfmpegDumpThreadSnafu)??; + trace!("thread dumping data into ffmpeg terminated"); + } + + #[cfg(feature = "vnc")] + { + trace!("Sending termination signal to vnc thread"); + if let Err(err) = vnc_terminate_signal_tx.send(()) { + warn!( + "Failed to send termination signal to vnc thread, it seems to already have terminated: {err:?}", + ) + } + trace!("Joining vnc thread"); + vnc_server_thread + .join() + .map_err(|_| Error::StopVncServerThread {})??; + trace!("Vnc thread terminated"); + } + + // We need to stop this thread as the last, as others always try to send statistics to it + statistics_thread.abort(); + + if ffmpeg_thread_present { + info!("Successfully shut down (there might still be a ffmped process running - it's complicated)"); + } else { + info!("Successfully shut down"); + } + + Ok(()) +} diff --git a/breakwater/src/prometheus_exporter.rs b/breakwater/src/prometheus_exporter.rs new file mode 100644 index 0000000..4f2cf48 --- /dev/null +++ b/breakwater/src/prometheus_exporter.rs @@ -0,0 +1,125 @@ +use std::net::AddrParseError; + +use prometheus_exporter::{ + self, + prometheus::{register_int_gauge, register_int_gauge_vec, IntGauge, IntGaugeVec}, +}; +use snafu::{ResultExt, Snafu}; +use tokio::sync::broadcast; + +use crate::statistics::StatisticsInformationEvent; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to parse Prometheus listen address {listen_address:?}"))] + ParseListenAddress { + source: AddrParseError, + listen_address: String, + }, + + #[snafu(display("Failed to start Prometheus server"))] + StartPrometheusServer { source: prometheus_exporter::Error }, + + #[snafu(display("Failed to register prometheus gauge {name:?}"))] + RegisterPrometheusGauge { + source: prometheus_exporter::prometheus::Error, + name: String, + }, +} + +pub struct PrometheusExporter { + statistics_information_rx: broadcast::Receiver, + + // Prometheus metrics + metric_ips: IntGauge, + metric_legacy_ips: IntGauge, + metric_frame: IntGauge, + metric_statistic_events: IntGauge, + + metric_connections_for_ip: IntGaugeVec, + metric_bytes_for_ip: IntGaugeVec, +} + +impl PrometheusExporter { + pub fn new( + listen_addr: &str, + statistics_information_rx: broadcast::Receiver, + ) -> Result { + let listen_addr = listen_addr.parse().context(ParseListenAddressSnafu { + listen_address: listen_addr.to_string(), + })?; + + prometheus_exporter::start(listen_addr).context(StartPrometheusServerSnafu)?; + + Ok(PrometheusExporter { + statistics_information_rx, + metric_legacy_ips: register_int_gauge( + "breakwater_ips", + "Total number of IPs connected", + )?, + metric_ips: register_int_gauge( + "breakwater_legacy_ips", + "Total number of legacy (v4) IPs connected", + )?, + metric_frame: register_int_gauge("breakwater_frame", "Frame number of the VNC server")?, + metric_statistic_events: register_int_gauge( + "breakwater_statistic_events", + "Number of statistics events send internally", + )?, + metric_connections_for_ip: register_int_gauge_vec( + "breakwater_connections", + "Number of client connections per IP address", + &["ip"], + )?, + metric_bytes_for_ip: register_int_gauge_vec( + "breakwater_bytes", + "Number of bytes received per IP address", + &["ip"], + )?, + }) + } + + pub async fn run(&mut self) { + while let Ok(event) = self.statistics_information_rx.recv().await { + self.metric_ips.set(event.ips as i64); + self.metric_legacy_ips.set(event.legacy_ips as i64); + self.metric_frame.set(event.frame as i64); + self.metric_statistic_events + .set(event.statistic_events as i64); + + // When clients drop a connection the item will be missing in `event.connections_for_ip, + // but would stay forever in the Prometheus metric + self.metric_connections_for_ip.reset(); + event + .connections_for_ip + .iter() + .for_each(|(ip, connections)| { + self.metric_connections_for_ip + .with_label_values(&[&ip.to_string()]) + .set(*connections as i64) + }); + self.metric_bytes_for_ip.reset(); + event.bytes_for_ip.iter().for_each(|(ip, bytes)| { + self.metric_bytes_for_ip + .with_label_values(&[&ip.to_string()]) + .set(*bytes as i64) + }); + } + } +} + +fn register_int_gauge(name: &str, description: &str) -> Result { + register_int_gauge!(name, description).context(RegisterPrometheusGaugeSnafu { + name: name.to_string(), + }) +} + +fn register_int_gauge_vec( + name: &str, + description: &str, + label_names: &[&str], +) -> Result { + register_int_gauge_vec!(name, description, label_names).context(RegisterPrometheusGaugeSnafu { + name: name.to_string(), + }) +} diff --git a/breakwater/src/server.rs b/breakwater/src/server.rs new file mode 100644 index 0000000..8db2b0f --- /dev/null +++ b/breakwater/src/server.rs @@ -0,0 +1,199 @@ +use std::{cmp::min, net::IpAddr, sync::Arc, time::Duration}; + +use breakwater_core::framebuffer::FrameBuffer; +use breakwater_parser::{original::OriginalParser, Parser, ParserError}; +use log::{debug, info}; +use snafu::{ResultExt, Snafu}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::mpsc, + time::Instant, +}; + +use crate::statistics::StatisticsEvent; + +// Every client connection spawns a new thread, so we need to limit the number of stat events we send +const STATISTICS_REPORT_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to bind to listen address {listen_address:?}"))] + BindToListenAddress { + source: std::io::Error, + listen_address: String, + }, + + #[snafu(display("Failed to accept new client connection"))] + AcceptNewClientConnection { source: std::io::Error }, + + #[snafu(display("Failed to write to statistics channel"))] + WriteToStatisticsChannel { + source: mpsc::error::SendError, + }, + + #[snafu(display("Failed to parse Pixelflut commands"))] + ParsePixelflutCommands { source: ParserError }, +} + +pub struct Server { + // listen_address: String, + listener: TcpListener, + fb: Arc, + statistics_tx: mpsc::Sender, + network_buffer_size: usize, +} + +impl Server { + pub async fn new( + listen_address: &str, + fb: Arc, + statistics_tx: mpsc::Sender, + network_buffer_size: usize, + ) -> Result { + let listener = TcpListener::bind(listen_address) + .await + .context(BindToListenAddressSnafu { listen_address })?; + info!("Started Pixelflut server on {listen_address}"); + + Ok(Self { + listener, + fb, + statistics_tx, + network_buffer_size, + }) + } + + pub async fn start(&self) -> Result<(), Error> { + loop { + let (socket, socket_addr) = self + .listener + .accept() + .await + .context(AcceptNewClientConnectionSnafu)?; + // If you connect via IPv4 you often show up as embedded inside an IPv6 address + // Extracting the embedded information here, so we get the real (TM) address + let ip = socket_addr.ip().to_canonical(); + + let fb_for_thread = Arc::clone(&self.fb); + let statistics_tx_for_thread = self.statistics_tx.clone(); + let network_buffer_size = self.network_buffer_size; + tokio::spawn(async move { + handle_connection( + socket, + ip, + fb_for_thread, + statistics_tx_for_thread, + network_buffer_size, + ) + .await + }); + } + } +} + +pub async fn handle_connection( + mut stream: impl AsyncReadExt + AsyncWriteExt + Send + Unpin, + ip: IpAddr, + fb: Arc, + statistics_tx: mpsc::Sender, + network_buffer_size: usize, +) -> Result<(), Error> { + debug!("Handling connection from {ip}"); + + statistics_tx + .send(StatisticsEvent::ConnectionCreated { ip }) + .await + .context(WriteToStatisticsChannelSnafu)?; + + let mut buffer = vec![0u8; network_buffer_size]; + // Number bytes left over **on the first bytes of the buffer** from the previous loop iteration + let mut leftover_bytes_in_buffer = 0; + + // Not using `ParserImplementation` to avoid the dynamic dispatch. + // let mut parser = ParserImplementation::Simple(SimpleParser::new(fb)); + let mut parser = OriginalParser::new(fb); + let parser_lookahead = parser.parser_lookahead(); + + // If we send e.g. an StatisticsEvent::BytesRead for every time we read something from the socket the statistics thread would go crazy. + // Instead we bulk the statistics and send them pre-aggregated. + let mut last_statistics = Instant::now(); + let mut statistics_bytes_read: u64 = 0; + + loop { + // Fill the buffer up with new data from the socket + // If there are any bytes left over from the previous loop iteration leave them as is and but the new data behind + let bytes_read = match stream + .read(&mut buffer[leftover_bytes_in_buffer..network_buffer_size - parser_lookahead]) + .await + { + Ok(bytes_read) => bytes_read, + Err(_) => { + break; + } + }; + + statistics_bytes_read += bytes_read as u64; + if last_statistics.elapsed() > STATISTICS_REPORT_INTERVAL { + statistics_tx + // We use a blocking call here as we want to process the stats. + // Otherwise the stats will lag behind resulting in weird spikes in bytes/s statistics. + // As the statistics calculation should be trivial let's wait for it + .send(StatisticsEvent::BytesRead { + ip, + bytes: statistics_bytes_read, + }) + .await + .context(WriteToStatisticsChannelSnafu)?; + last_statistics = Instant::now(); + statistics_bytes_read = 0; + } + + let data_end = leftover_bytes_in_buffer + bytes_read; + if bytes_read == 0 { + if leftover_bytes_in_buffer == 0 { + // We read no data and the previous loop did consume all data + // Nothing to do here, closing connection + break; + } + + // No new data from socket, read to the end and everything should be fine + leftover_bytes_in_buffer = 0; + } else { + // We have read some data, process it + + // We need to zero the PARSER_LOOKAHEAD bytes, so the parser does not detect any command left over from a previous loop iteration + for i in &mut buffer[data_end..data_end + parser_lookahead] { + *i = 0; + } + + let last_byte_parsed = parser + .parse(&buffer[..data_end + parser_lookahead], &mut stream) + .await + .context(ParsePixelflutCommandsSnafu)?; + + // IMPORTANT: We have to subtract 1 here, as e.g. we have "PX 0 0\n" data_end is 7 and parser_state.last_byte_parsed is 6. + // This happens, because last_byte_parsed is an index starting at 0, so index 6 is from an array of length 7 + leftover_bytes_in_buffer = data_end.saturating_sub(last_byte_parsed).saturating_sub(1); + + // There is no need to leave anything longer than a command can take + // This prevents malicious clients from sending gibberish and the buffer not getting drained + leftover_bytes_in_buffer = min(leftover_bytes_in_buffer, parser_lookahead); + + if leftover_bytes_in_buffer > 0 { + // We need to move the leftover bytes to the beginning of the buffer so that the next loop iteration con work on them + buffer.copy_within( + last_byte_parsed + 1..last_byte_parsed + 1 + leftover_bytes_in_buffer, + 0, + ); + } + } + } + + statistics_tx + .send(StatisticsEvent::ConnectionClosed { ip }) + .await + .context(WriteToStatisticsChannelSnafu)?; + + Ok(()) +} diff --git a/src/sinks/ffmpeg.rs b/breakwater/src/sinks/ffmpeg.rs similarity index 53% rename from src/sinks/ffmpeg.rs rename to breakwater/src/sinks/ffmpeg.rs index 23cffc4..b876c6e 100644 --- a/src/sinks/ffmpeg.rs +++ b/breakwater/src/sinks/ffmpeg.rs @@ -1,9 +1,29 @@ use std::{process::Stdio, sync::Arc, time::Duration}; +use breakwater_core::framebuffer::FrameBuffer; use chrono::Local; -use tokio::{io::AsyncWriteExt, process::Command, sync::oneshot::Receiver, time}; +use log::debug; +use snafu::{ResultExt, Snafu}; +use tokio::{ + io::AsyncWriteExt, + process::Command, + sync::oneshot::Receiver, + time::{self}, +}; -use crate::{args::Args, framebuffer::FrameBuffer}; +use crate::cli_args::CliArgs; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to start ffmpeg command {command:?}"))] + StartFfmpeg { + source: std::io::Error, + command: String, + }, + + #[snafu(display("Failed to write new data to ffmpeg via stdout"))] + WriteDataToFfmeg { source: std::io::Error }, +} pub struct FfmpegSink { fb: Arc, @@ -13,7 +33,7 @@ pub struct FfmpegSink { } impl FfmpegSink { - pub fn new(args: &Args, fb: Arc) -> Option { + pub fn new(args: &CliArgs, fb: Arc) -> Option { if args.rtmp_address.is_some() || args.video_save_folder.is_some() { Some(FfmpegSink { fb, @@ -26,10 +46,7 @@ impl FfmpegSink { } } - pub async fn run<'a>( - &self, - mut terminate_signal_rx: Receiver<&'a str>, - ) -> tokio::io::Result<()> { + pub async fn run(&self, mut terminate_signal_rx: Receiver<()>) -> Result<(), Error> { let mut ffmpeg_args: Vec = self .ffmpeg_input_args() .into_iter() @@ -39,16 +56,13 @@ impl FfmpegSink { match &self.rtmp_address { Some(rtmp_address) => match &self.video_save_folder { Some(video_save_folder) => { + // Write to rtmp and file ffmpeg_args.extend( self.ffmpeg_rtmp_sink_args() .into_iter() .flat_map(|(arg, value)| [format!("-{arg}"), value]) .collect::>(), ); - let video_file = format!( - "{video_save_folder}/pixelflut_dump_{}.mp4", - Local::now().format("%Y-%m-%d_%H-%M-%S") - ); ffmpeg_args.extend([ "-f".to_string(), "tee".to_string(), @@ -58,12 +72,15 @@ impl FfmpegSink { "1:a".to_string(), format!( "{video_file}|[f=flv]{rtmp_address}", + video_file = Self::video_file(video_save_folder), rtmp_address = rtmp_address.clone(), ), ]); - todo!("Writing to file and rtmp sink simultaneously currently not supported"); + + todo!("Writing to file and rtmp sink simultaneously currently not supported, sorry!"); } None => { + // Only write to rtmp ffmpeg_args.extend( self.ffmpeg_rtmp_sink_args() .into_iter() @@ -74,12 +91,9 @@ impl FfmpegSink { } }, None => match &self.video_save_folder { + // Only write to file Some(video_save_folder) => { - let video_file = format!( - "{video_save_folder}/pixelflut_dump_{}.mp4", - Local::now().format("%Y-%m-%d_%H-%M-%S") - ); - ffmpeg_args.extend([video_file]) + ffmpeg_args.extend([Self::video_file(video_save_folder)]) } None => unreachable!( "FfmpegSink can only be created when either rtmp or video file is activated" @@ -87,13 +101,16 @@ impl FfmpegSink { }, } - log::info!("ffmpeg {}", ffmpeg_args.join(" ")); + let ffmpeg_command = format!("ffmpeg {}", ffmpeg_args.join(" ")); + debug!("Executing {ffmpeg_command:?}"); let mut command = Command::new("ffmpeg") .kill_on_drop(false) - .args(ffmpeg_args) + .args(ffmpeg_args.clone()) .stdin(Stdio::piped()) .spawn() - .unwrap(); + .context(StartFfmpegSnafu { + command: ffmpeg_command, + })?; let mut stdin = command .stdin @@ -103,17 +120,50 @@ impl FfmpegSink { let mut interval = time::interval(Duration::from_micros(1_000_000 / 30)); loop { if terminate_signal_rx.try_recv().is_ok() { - command.kill().await?; + // Normally we would send SIGINT to ffmpeg and let the process shutdown gracefully and afterwards call + // `command.wait().await`. Hopever using the `nix` crate to send a `SIGINT` resulted in ffmpeg + // [2024-05-14T21:35:25Z TRACE breakwater::sinks::ffmpeg] Sending SIGINT to ffmpeg process with pid 58786 + // [out#0/mp4 @ 0x1048740] Error writing trailer: Immediate exit requested + // + // As you can see this also corrupted the output mp4 :( + // So instead we let the process running here and let the kernel clean up (?), which seems to work (?) + + // trace!("Killing ffmpeg process"); + + // if cfg!(target_os = "linux") { + // if let Some(pid) = command.id() { + // trace!("Sending SIGINT to ffmpeg process with pid {pid}"); + // nix::sys::signal::kill( + // nix::unistd::Pid::from_raw(pid.try_into().unwrap()), + // nix::sys::signal::Signal::SIGINT, + // ) + // .unwrap(); + // } else { + // error!("The ffmpeg process had no PID, so I could not kill it. Will let tokio kill it instead"); + // command.start_kill().unwrap(); + // } + // } else { + // trace!("As I'm not on Linux, YOLO-ing it by letting tokio kill it "); + // command.start_kill().unwrap(); + // } + + // let start = Instant::now(); + // command.wait().await.unwrap(); + // trace!("Killied ffmpeg process in {:?}", start.elapsed()); + return Ok(()); } let bytes = self.fb.as_bytes(); - stdin.write_all(bytes).await?; + stdin + .write_all(bytes) + .await + .context(WriteDataToFfmegSnafu)?; interval.tick().await; } } fn ffmpeg_input_args(&self) -> Vec<(String, String)> { - let video_size: String = format!("{}x{}", self.fb.get_width(), self.fb.get_height()); + let video_size = format!("{}x{}", self.fb.get_width(), self.fb.get_height()); [ ("f", "rawvideo"), ("pixel_format", "rgb0"), @@ -143,4 +193,11 @@ impl FfmpegSink { .map(|(s1, s2)| (s1.to_string(), s2.to_string())) .into() } + + fn video_file(video_save_folder: &str) -> String { + format!( + "{video_save_folder}/pixelflut_dump_{}.mp4", + Local::now().format("%Y-%m-%d_%H-%M-%S") + ) + } } diff --git a/src/sinks/mod.rs b/breakwater/src/sinks/mod.rs similarity index 100% rename from src/sinks/mod.rs rename to breakwater/src/sinks/mod.rs diff --git a/src/sinks/vnc.rs b/breakwater/src/sinks/vnc.rs similarity index 72% rename from src/sinks/vnc.rs rename to breakwater/src/sinks/vnc.rs index d117241..496e6b7 100644 --- a/src/sinks/vnc.rs +++ b/breakwater/src/sinks/vnc.rs @@ -1,44 +1,92 @@ -use crate::framebuffer::FrameBuffer; -use crate::statistics::{StatisticsEvent, StatisticsInformationEvent}; +use std::{sync::Arc, time::Duration}; + +use breakwater_core::framebuffer::FrameBuffer; use core::slice; use number_prefix::NumberPrefix; use rusttype::{point, Font, Scale}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc::Sender; -use tokio::sync::{broadcast, oneshot}; +use snafu::{OptionExt, ResultExt, Snafu}; +use tokio::sync::{ + broadcast, + mpsc::{self, Sender}, + oneshot, +}; use vncserver::{ rfb_framebuffer_malloc, rfb_get_screen, rfb_init_server, rfb_mark_rect_as_modified, rfb_run_event_loop, RfbScreenInfoPtr, }; +use crate::statistics::{StatisticsEvent, StatisticsInformationEvent}; + const STATS_HEIGHT: usize = 35; -pub struct VncServer<'a, 'b> { +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to read font from file {font_file}"))] + ReadFontFile { + source: std::io::Error, + font_file: String, + }, + + #[snafu(display("Failed to construct font from font file {font_file}"))] + ConstructFontFromFontFile { font_file: String }, + + #[snafu(display("Failed to write to statistics channel"))] + WriteToStatisticsChannel { + source: mpsc::error::SendError, + }, + + #[snafu(display("Failed to read from statistics information channel"))] + ReadFromStatisticsInformationChannel { + source: broadcast::error::TryRecvError, + }, +} + +// Sorry! Help needed :) +unsafe impl<'a> Send for VncServer<'a> {} +pub struct VncServer<'a> { fb: Arc, screen: RfbScreenInfoPtr, target_fps: u32, statistics_tx: Sender, statistics_information_rx: broadcast::Receiver, - terminate_signal_tx: oneshot::Receiver<&'b str>, + terminate_signal_tx: oneshot::Receiver<()>, - text: &'a str, + text: String, font: Font<'a>, } -impl<'a, 'b> VncServer<'a, 'b> { +impl<'a> VncServer<'a> { #[allow(clippy::too_many_arguments)] pub fn new( fb: Arc, - port: u32, + port: u16, target_fps: u32, statistics_tx: Sender, statistics_information_rx: broadcast::Receiver, - terminate_signal_tx: oneshot::Receiver<&'b str>, - text: &'a str, - font: &'a str, - ) -> Self { + terminate_signal_tx: oneshot::Receiver<()>, + text: String, + font: String, + ) -> Result { + let font = match font.as_str() { + // We ship our own copy of Arial.ttf, so that users don't need to download and provide it + "Arial.ttf" => { + let font_bytes = include_bytes!("../../../Arial.ttf"); + Font::try_from_bytes(font_bytes).context(ConstructFontFromFontFileSnafu { + font_file: "Arial.ttf".to_string(), + })? + } + _ => { + let font_bytes = std::fs::read(&font).context(ReadFontFileSnafu { + font_file: font.to_string(), + })?; + + Font::try_from_vec(font_bytes).context(ConstructFontFromFontFileSnafu { + font_file: font.to_string(), + })? + } + }; + let screen = rfb_get_screen(fb.get_width() as i32, fb.get_height() as i32, 8, 3, 4); unsafe { // We need to set bitsPerPixel and depth to the correct values, @@ -56,22 +104,7 @@ impl<'a, 'b> VncServer<'a, 'b> { rfb_init_server(screen); rfb_run_event_loop(screen, 1, 1); - let font = match font { - // We ship our own copy of Arial.ttf, so that users don't need to download and provide it - "Arial.ttf" => { - let font_bytes = include_bytes!("../../Arial.ttf"); - Font::try_from_bytes(font_bytes) - .unwrap_or_else(|| panic!("Failed to construct Font from Arial.ttf")) - } - _ => { - let font_bytes = std::fs::read(font) - .unwrap_or_else(|err| panic!("Failed to read font file {font}: {err}")); - Font::try_from_vec(font_bytes) - .unwrap_or_else(|| panic!("Failed to construct Font from font file {font}")) - } - }; - - VncServer { + Ok(VncServer { fb, screen, target_fps, @@ -80,29 +113,27 @@ impl<'a, 'b> VncServer<'a, 'b> { terminate_signal_tx, text, font, - } + }) } - pub fn run(&mut self) { + pub fn run(&mut self) -> Result<(), Error> { let target_loop_duration = Duration::from_micros(1_000_000 / self.target_fps as u64); - let fb = &self.fb; let vnc_fb_slice: &mut [u32] = unsafe { - slice::from_raw_parts_mut((*self.screen).frameBuffer as *mut u32, fb.get_size()) + slice::from_raw_parts_mut((*self.screen).frameBuffer as *mut u32, self.fb.get_size()) }; - let fb_slice = unsafe { &*fb.get_buffer() }; // A line less because the (height - STATS_SURFACE_HEIGHT) belongs to the stats and gets refreshed by them let height_up_to_stats_text = self.fb.get_height() - STATS_HEIGHT - 1; - let fb_size_up_to_stats_text = fb.get_width() * height_up_to_stats_text; + let fb_size_up_to_stats_text = self.fb.get_width() * height_up_to_stats_text; loop { if self.terminate_signal_tx.try_recv().is_ok() { - return; + return Ok(()); } let start = std::time::Instant::now(); vnc_fb_slice[0..fb_size_up_to_stats_text] - .copy_from_slice(&fb_slice[0..fb_size_up_to_stats_text]); + .copy_from_slice(&self.fb.get_buffer()[0..fb_size_up_to_stats_text]); // Only refresh the drawing surface, not the stats surface rfb_mark_rect_as_modified( @@ -114,11 +145,13 @@ impl<'a, 'b> VncServer<'a, 'b> { ); self.statistics_tx .blocking_send(StatisticsEvent::FrameRendered) - .unwrap(); + .context(WriteToStatisticsChannelSnafu)?; if !self.statistics_information_rx.is_empty() { - let statistics_information_event = - self.statistics_information_rx.try_recv().unwrap(); + let statistics_information_event = self + .statistics_information_rx + .try_recv() + .context(ReadFromStatisticsInformationChannelSnafu)?; self.display_stats(statistics_information_event); } diff --git a/src/statistics.rs b/breakwater/src/statistics.rs similarity index 76% rename from src/statistics.rs rename to breakwater/src/statistics.rs index 1984736..83467be 100644 --- a/src/statistics.rs +++ b/breakwater/src/statistics.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use simple_moving_average::{SingleSumSMA, SMA}; +use snafu::{ResultExt, Snafu}; use std::{ cmp::max, collections::{hash_map::Entry, HashMap}, @@ -7,11 +8,37 @@ use std::{ net::IpAddr, time::{Duration, Instant}, }; -use tokio::sync::{broadcast, mpsc::Receiver}; +use tokio::sync::{broadcast, mpsc}; pub const STATS_REPORT_INTERVAL: Duration = Duration::from_millis(1000); pub const STATS_SLIDING_WINDOW_SIZE: usize = 5; +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to create statistics save file {save_file}"))] + CreateStatisticsSaveFile { + source: std::io::Error, + save_file: String, + }, + + #[snafu(display("Failed to open statistics save file {save_file}"))] + OpenStatisticsSaveFile { + source: std::io::Error, + save_file: String, + }, + + #[snafu(display("Failed to serialize statistics to save file"))] + SerializeStatistics { source: serde_json::Error }, + + #[snafu(display("Failed to deserialize statistics from save file"))] + DeserializeStatistics { source: serde_json::Error }, + + #[snafu(display("Failed to write to statistics information channel"))] + WriteToStatisticsInformationChannel { + source: Box>, + }, +} + #[derive(Debug)] pub enum StatisticsEvent { ConnectionCreated { ip: IpAddr }, @@ -42,7 +69,7 @@ pub struct StatisticsInformationEvent { } pub struct Statistics { - statistics_rx: Receiver, + statistics_rx: mpsc::Receiver, statistics_information_tx: broadcast::Sender, statistic_events: u64, @@ -57,27 +84,31 @@ pub struct Statistics { } impl StatisticsInformationEvent { - fn save_to_file(&self, file_name: &str) -> std::io::Result<()> { + fn save_to_file(&self, file_name: &str) -> Result<(), Error> { // TODO Check if we can use tokio's File here. This needs some integration with serde_json though // This operation is also called very infrequently - let file = File::create(file_name)?; - serde_json::to_writer(file, &self)?; + let file = File::create(file_name).context(CreateStatisticsSaveFileSnafu { + save_file: file_name.to_string(), + })?; + serde_json::to_writer(file, &self).context(SerializeStatisticsSnafu)?; Ok(()) } - fn load_from_file(file_name: &str) -> std::io::Result { - let file = File::open(file_name)?; - Ok(serde_json::from_reader(file)?) + fn load_from_file(file_name: &str) -> Result { + let file = File::open(file_name).context(OpenStatisticsSaveFileSnafu { + save_file: file_name.to_string(), + })?; + serde_json::from_reader(file).context(DeserializeStatisticsSnafu) } } impl Statistics { pub fn new( - statistics_rx: Receiver, + statistics_rx: mpsc::Receiver, statistics_information_tx: broadcast::Sender, statistics_save_mode: StatisticsSaveMode, - ) -> std::io::Result { + ) -> Self { let mut statistics = Statistics { statistics_rx, statistics_information_tx, @@ -91,6 +122,7 @@ impl Statistics { }; if let StatisticsSaveMode::Enabled { save_file, .. } = &statistics.statistics_save_mode { + // There might not be a save point on first start if let Ok(save_point) = StatisticsInformationEvent::load_from_file(save_file) { statistics.statistic_events = save_point.statistic_events; statistics.frame = save_point.frame; @@ -98,10 +130,10 @@ impl Statistics { } } - Ok(statistics) + statistics } - pub async fn start(&mut self) -> std::io::Result<()> { + pub async fn start(&mut self) -> Result<(), Error> { let mut last_stat_report = Instant::now(); let mut last_save_file_written = Instant::now(); let mut statistics_information_event = StatisticsInformationEvent::default(); @@ -137,7 +169,8 @@ impl Statistics { ); self.statistics_information_tx .send(statistics_information_event.clone()) - .expect("Statistics information channel full (or disconnected)"); + .map_err(Box::new) + .context(WriteToStatisticsInformationChannelSnafu)?; if let StatisticsSaveMode::Enabled { save_file, diff --git a/breakwater/src/tests.rs b/breakwater/src/tests.rs new file mode 100644 index 0000000..521ce18 --- /dev/null +++ b/breakwater/src/tests.rs @@ -0,0 +1,271 @@ +use std::{ + net::{IpAddr, Ipv4Addr}, + sync::Arc, +}; + +use breakwater_core::{framebuffer::FrameBuffer, test_helpers::MockTcpStream, HELP_TEXT}; +use rstest::{fixture, rstest}; +use tokio::sync::mpsc; + +use crate::{ + cli_args::DEFAULT_NETWORK_BUFFER_SIZE, server::handle_connection, statistics::StatisticsEvent, +}; + +#[fixture] +fn ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)) +} + +#[fixture] +fn fb() -> Arc { + Arc::new(FrameBuffer::new(1920, 1080)) +} + +#[fixture] +fn statistics_channel() -> ( + mpsc::Sender, + mpsc::Receiver, +) { + mpsc::channel(10000) +} + +#[rstest] +#[timeout(std::time::Duration::from_secs(1))] +#[case("", "")] +#[case("\n", "")] +#[case("not a pixelflut command", "")] +#[case("not a pixelflut command with newline\n", "")] +#[case("SIZE", "SIZE 1920 1080\n")] +#[case("SIZE\n", "SIZE 1920 1080\n")] +#[case("SIZE\nSIZE\n", "SIZE 1920 1080\nSIZE 1920 1080\n")] +#[case("SIZE", "SIZE 1920 1080\n")] +#[case("HELP", std::str::from_utf8(HELP_TEXT).unwrap())] +#[case("HELP\n", std::str::from_utf8(HELP_TEXT).unwrap())] +#[case("bla bla bla\nSIZE\nblub\nbla", "SIZE 1920 1080\n")] +#[tokio::test] +async fn test_correct_responses_to_general_commands( + #[case] input: &str, + #[case] expected: &str, + ip: IpAddr, + fb: Arc, + statistics_channel: ( + mpsc::Sender, + mpsc::Receiver, + ), +) { + let mut stream = MockTcpStream::from_input(input); + handle_connection( + &mut stream, + ip, + fb, + statistics_channel.0, + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + + assert_eq!(expected, stream.get_output()); +} + +#[rstest] +// Without alpha +#[case("PX 0 0 ffffff\nPX 0 0\n", "PX 0 0 ffffff\n")] +#[case("PX 0 0 abcdef\nPX 0 0\n", "PX 0 0 abcdef\n")] +#[case("PX 0 42 abcdef\nPX 0 42\n", "PX 0 42 abcdef\n")] +#[case("PX 42 0 abcdef\nPX 42 0\n", "PX 42 0 abcdef\n")] +// With alpha +#[case("PX 0 0 ffffff00\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 000000\n"} else {"PX 0 0 ffffff\n"})] +#[case("PX 0 0 ffffffff\nPX 0 0\n", "PX 0 0 ffffff\n")] +#[case("PX 0 1 abcdef00\nPX 0 1\n", if cfg!(feature = "alpha") {"PX 0 1 000000\n"} else {"PX 0 1 abcdef\n"})] +#[case("PX 1 0 abcdefff\nPX 1 0\n", "PX 1 0 abcdef\n")] +#[case("PX 0 0 ffffff88\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 888888\n"} else {"PX 0 0 ffffff\n"})] +#[case("PX 0 0 ffffff11\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 111111\n"} else {"PX 0 0 ffffff\n"})] +#[case("PX 0 0 abcdef80\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 556677\n"} else {"PX 0 0 abcdef\n"})] +// 0xab = 171, 0x88 = 136 +// (171 * 136) / 255 = 91 = 0x5b +#[case("PX 0 0 abcdef88\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 5b6d7f\n"} else {"PX 0 0 abcdef\n"})] +// Short commands +#[case("PX 0 0 00\nPX 0 0\n", "PX 0 0 000000\n")] +#[case("PX 0 0 ff\nPX 0 0\n", "PX 0 0 ffffff\n")] +#[case("PX 0 1 12\nPX 0 1\n", "PX 0 1 121212\n")] +#[case("PX 0 1 34\nPX 0 1\n", "PX 0 1 343434\n")] +// Tests invalid bounds +#[case("PX 9999 0 abcdef\nPX 9999 0\n", "")] // Parsable but outside screen size +#[case("PX 0 9999 abcdef\nPX 9999 0\n", "")] +#[case("PX 9999 9999 abcdef\nPX 9999 9999\n", "")] +#[case("PX 99999 0 abcdef\nPX 0 99999\n", "")] // Not even parsable because to many digits +#[case("PX 0 99999 abcdef\nPX 0 99999\n", "")] +#[case("PX 99999 99999 abcdef\nPX 99999 99999\n", "")] +// Test invalid inputs +#[case("PX 0 abcdef\nPX 0 0\n", "PX 0 0 000000\n")] +#[case("PX 0 1 2 abcdef\nPX 0 0\n", "PX 0 0 000000\n")] +#[case("PX -1 0 abcdef\nPX 0 0\n", "PX 0 0 000000\n")] +#[case("bla bla bla\nPX 0 0\n", "PX 0 0 000000\n")] +// Test offset +#[case( + "OFFSET 10 10\nPX 0 0 ffffff\nPX 0 0\nPX 42 42\n", + "PX 0 0 ffffff\nPX 42 42 000000\n" +)] // The get pixel result is also offseted +#[case("OFFSET 0 0\nPX 0 42 abcdef\nPX 0 42\n", "PX 0 42 abcdef\n")] +#[tokio::test] +async fn test_setting_pixel( + #[case] input: &str, + #[case] expected: &str, + ip: IpAddr, + fb: Arc, + statistics_channel: ( + mpsc::Sender, + mpsc::Receiver, + ), +) { + let mut stream = MockTcpStream::from_input(input); + handle_connection( + &mut stream, + ip, + fb, + statistics_channel.0, + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + + assert_eq!(expected, stream.get_output()); +} + +#[rstest] +#[case("PX 0 0 aaaaaa\n")] +#[case("PX 0 0 aa\n")] +#[tokio::test] +async fn test_safe( + #[case] input: &str, + ip: IpAddr, + fb: Arc, + statistics_channel: ( + mpsc::Sender, + mpsc::Receiver, + ), +) { + let mut stream = MockTcpStream::from_input(input); + handle_connection( + &mut stream, + ip, + fb.clone(), + statistics_channel.0, + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + + // Test if it panics + assert_eq!(fb.get(0, 0).unwrap() & 0x00ff_ffff, 0xaaaaaa); +} + +#[rstest] +#[case(5, 5, 0, 0)] +#[case(6, 6, 0, 0)] +#[case(7, 7, 0, 0)] +#[case(8, 8, 0, 0)] +#[case(9, 9, 0, 0)] +#[case(10, 10, 0, 0)] +#[case(10, 10, 100, 200)] +#[case(10, 10, 510, 520)] +#[case(100, 100, 0, 0)] +#[case(100, 100, 300, 400)] +#[case(479, 361, 721, 391)] +#[case(500, 500, 0, 0)] +#[case(500, 500, 300, 400)] +#[case(fb().get_width(), fb().get_height(), 0, 0)] +#[case(fb().get_width() - 1, fb().get_height() - 1, 1, 1)] +#[tokio::test] +async fn test_drawing_rect( + #[case] width: usize, + #[case] height: usize, + #[case] offset_x: usize, + #[case] offset_y: usize, + ip: IpAddr, + fb: Arc, + statistics_channel: ( + mpsc::Sender, + mpsc::Receiver, + ), +) { + let mut color: u32 = 0; + let mut fill_commands = String::new(); + let mut read_commands = String::new(); + let mut combined_commands = String::new(); + let mut combined_commands_expected = String::new(); + let mut read_other_pixels_commands = String::new(); + let mut read_other_pixels_commands_expected = String::new(); + + for x in 0..fb.get_width() { + for y in 0..height { + // Inside the rect + if x >= offset_x && x <= offset_x + width && y >= offset_y && y <= offset_y + height { + fill_commands += &format!("PX {x} {y} {color:06x}\n"); + read_commands += &format!("PX {x} {y}\n"); + + color += 1; // Use another color for the next test case + combined_commands += &format!("PX {x} {y} {color:06x}\nPX {x} {y}\n"); + combined_commands_expected += &format!("PX {x} {y} {color:06x}\n"); + + color += 1; + } else { + // Non touched pixels must remain black + read_other_pixels_commands += &format!("PX {x} {y}\n"); + read_other_pixels_commands_expected += &format!("PX {x} {y} 000000\n"); + } + } + } + + // Color the pixels + let mut stream = MockTcpStream::from_input(&fill_commands); + handle_connection( + &mut stream, + ip, + Arc::clone(&fb), + statistics_channel.0.clone(), + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + assert_eq!("", stream.get_output()); + + // Read the pixels again + let mut stream = MockTcpStream::from_input(&read_commands); + handle_connection( + &mut stream, + ip, + Arc::clone(&fb), + statistics_channel.0.clone(), + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + assert_eq!(fill_commands, stream.get_output()); + + // We can also do coloring and reading in a single connection + let mut stream = MockTcpStream::from_input(&combined_commands); + handle_connection( + &mut stream, + ip, + Arc::clone(&fb), + statistics_channel.0.clone(), + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + assert_eq!(combined_commands_expected, stream.get_output()); + + // Check that nothing else was colored + let mut stream = MockTcpStream::from_input(&read_other_pixels_commands); + handle_connection( + &mut stream, + ip, + Arc::clone(&fb), + statistics_channel.0.clone(), + DEFAULT_NETWORK_BUFFER_SIZE, + ) + .await + .unwrap(); + assert_eq!(read_other_pixels_commands_expected, stream.get_output()); +} diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..db4a67a --- /dev/null +++ b/default.nix @@ -0,0 +1,14 @@ +{ nixpkgs ? import {} }: + +nixpkgs.mkShell { + buildInputs = [ + nixpkgs.pkg-config + nixpkgs.clang + nixpkgs.libclang + nixpkgs.libvncserver + nixpkgs.libvncserver.dev + ]; + + LIBCLANG_PATH = "${nixpkgs.libclang.lib}/lib"; + LIBVNCSERVER_HEADER_FILE = "${nixpkgs.libvncserver.dev}/include/rfb/rfb.h"; +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 203c3e3..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -#![feature(portable_simd)] - -pub mod args; -pub mod framebuffer; -pub mod network; -pub mod parser; -pub mod prometheus_exporter; -pub mod sinks; -pub mod statistics; -pub mod test; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 7336e38..0000000 --- a/src/main.rs +++ /dev/null @@ -1,117 +0,0 @@ -use breakwater::{ - args::Args, - framebuffer::FrameBuffer, - network::Network, - prometheus_exporter::PrometheusExporter, - sinks::ffmpeg::FfmpegSink, - statistics::{Statistics, StatisticsEvent, StatisticsInformationEvent, StatisticsSaveMode}, -}; -use clap::Parser; -use env_logger::Env; -use std::sync::Arc; -use tokio::sync::{broadcast, mpsc, oneshot}; -#[cfg(feature = "vnc")] -use { - breakwater::sinks::vnc::VncServer, - thread_priority::{ThreadBuilderExt, ThreadPriority}, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); - let args = Args::parse(); - - breakwater::parser::check_cpu_support(); - - let fb = Arc::new(FrameBuffer::new(args.width, args.height)); - - // If we make the channel to big, stats will start to lag behind - // TODO: Check performance impact in real-world scenario. Maybe the statistics thread blocks the other threads - let (statistics_tx, statistics_rx) = mpsc::channel::(100); - let (statistics_information_tx, statistics_information_rx_for_prometheus_exporter) = - broadcast::channel::(2); - let (ffmpeg_terminate_signal_tx, ffmpeg_terminate_signal_rx) = oneshot::channel(); - #[cfg(feature = "vnc")] - let (vnc_terminate_signal_tx, vnc_terminate_signal_rx) = oneshot::channel(); - #[cfg(feature = "vnc")] - let statistics_information_rx_for_vnc_server = statistics_information_tx.subscribe(); - - let statistics_save_mode = if args.disable_statistics_save_file { - StatisticsSaveMode::Disabled - } else { - StatisticsSaveMode::Enabled { - save_file: args.statistics_save_file.clone(), - interval_s: args.statistics_save_interval_s, - } - }; - let mut statistics = Statistics::new( - statistics_rx, - statistics_information_tx, - statistics_save_mode, - )?; - - let network = Network::new(&args.listen_address, Arc::clone(&fb), statistics_tx.clone()); - let network_listener_thread = tokio::spawn(async move { - network.listen().await.unwrap(); - }); - - let ffmpeg_sink = FfmpegSink::new(&args, Arc::clone(&fb)); - let ffmpeg_thread = ffmpeg_sink.map(|sink| { - tokio::spawn(async move { sink.run(ffmpeg_terminate_signal_rx).await.unwrap() }) - }); - - #[cfg(feature = "vnc")] - let vnc_server_thread = { - let fb_for_vnc_server = Arc::clone(&fb); - // TODO Use tokio::spawn instead of std::thread::spawn - // I was not able to get to work with async closure - // We than also need to think about setting a priority - std::thread::Builder::new() - .name("breakwater vnc server thread".to_owned()) - .spawn_with_priority( - ThreadPriority::Crossplatform(70.try_into().expect("Failed to get cross-platform ThreadPriority. Please report this error message together with your operating system.")), - move |_| { - let mut vnc_server = VncServer::new( - fb_for_vnc_server, - args.vnc_port, - args.fps, - statistics_tx, - statistics_information_rx_for_vnc_server, - vnc_terminate_signal_rx, - &args.text, - &args.font, - ); - vnc_server.run(); - }, - ) - .unwrap() - }; - - let statistics_thread = - tokio::spawn(async move { statistics.start().await.expect("Statistics thread failed") }); - - let mut prometheus_exporter = PrometheusExporter::new( - &args.prometheus_listen_address, - statistics_information_rx_for_prometheus_exporter, - ); - let prometheus_exporter_thread = tokio::spawn(async move { - prometheus_exporter.run().await; - }); - - tokio::signal::ctrl_c().await?; - - prometheus_exporter_thread.abort(); - network_listener_thread.abort(); - statistics_thread.abort(); - if let Some(ffmpeg_thread) = ffmpeg_thread { - ffmpeg_terminate_signal_tx.send("bye bye ffmpeg")?; - ffmpeg_thread.abort(); - } - #[cfg(feature = "vnc")] - { - vnc_terminate_signal_tx.send("bye bye vnc")?; - vnc_server_thread.join().unwrap(); - } - - Ok(()) -} diff --git a/src/network.rs b/src/network.rs deleted file mode 100644 index 705a441..0000000 --- a/src/network.rs +++ /dev/null @@ -1,405 +0,0 @@ -use crate::{ - framebuffer::FrameBuffer, - parser::{parse_pixelflut_commands, ParserState, PARSER_LOOKAHEAD}, - statistics::StatisticsEvent, -}; -use log::{debug, info}; -use std::{ - cmp::min, - net::{IpAddr, Ipv4Addr}, - sync::Arc, - time::Duration, -}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - sync::mpsc::Sender, - time::Instant, -}; - -const NETWORK_BUFFER_SIZE: usize = 256_000; -// Every client connection spawns a new thread, so we need to limit the number of stat events we send -const STATISTICS_REPORT_INTERVAL: Duration = Duration::from_millis(250); - -pub struct Network { - listen_address: String, - fb: Arc, - statistics_tx: Sender, -} - -impl Network { - pub fn new( - listen_address: &str, - fb: Arc, - statistics_tx: Sender, - ) -> Self { - Network { - listen_address: listen_address.to_string(), - fb, - statistics_tx, - } - } - - pub async fn listen(&self) -> tokio::io::Result<()> { - let listener = TcpListener::bind(&self.listen_address).await?; - info!("Started Pixelflut server on {}", self.listen_address); - - loop { - let (socket, socket_addr) = listener.accept().await?; - // If you connect via IPv4 you often show up as embedded inside an IPv6 address - // Extracting the embedded information here, so we get the real (TM) address - let ip = ip_to_canonical(socket_addr.ip()); - - let fb_for_thread = Arc::clone(&self.fb); - let statistics_tx_for_thread = self.statistics_tx.clone(); - tokio::spawn(async move { - handle_connection(socket, ip, fb_for_thread, statistics_tx_for_thread).await; - }); - } - } -} - -pub async fn handle_connection( - mut stream: impl AsyncReadExt + AsyncWriteExt + Unpin, - ip: IpAddr, - fb: Arc, - statistics_tx: Sender, -) { - debug!("Handling connection from {ip}"); - - statistics_tx - .send(StatisticsEvent::ConnectionCreated { ip }) - .await - .expect("Statistics channel disconnected"); - - // TODO: Try performance of Vec<> on heap instead of stack. Also bigger buffer - let mut buffer = [0u8; NETWORK_BUFFER_SIZE]; - // Number bytes left over **on the first bytes of the buffer** from the previous loop iteration - let mut leftover_bytes_in_buffer = 0; - - // We have to keep the some things - such as connection offset - for the whole connection lifetime, so let's define them here - let mut parser_state = ParserState::default(); - - // If we send e.g. an StatisticsEvent::BytesRead for every time we read something from the socket the statistics thread would go crazy. - // Instead we bulk the statistics and send them pre-aggregated. - let mut last_statistics = Instant::now(); - let mut statistics_bytes_read: u64 = 0; - - loop { - // Fill the buffer up with new data from the socket - // If there are any bytes left over from the previous loop iteration leave them as is and but the new data behind - let bytes_read = match stream - .read(&mut buffer[leftover_bytes_in_buffer..NETWORK_BUFFER_SIZE - PARSER_LOOKAHEAD]) - .await - { - Ok(bytes_read) => bytes_read, - Err(_) => { - break; - } - }; - - statistics_bytes_read += bytes_read as u64; - if last_statistics.elapsed() > STATISTICS_REPORT_INTERVAL { - statistics_tx - // We use a blocking call here as we want to process the stats. - // Otherwise the stats will lag behind resulting in weird spikes in bytes/s statistics. - // As the statistics calculation should be trivial let's wait for it - .send(StatisticsEvent::BytesRead { - ip, - bytes: statistics_bytes_read, - }) - .await - .expect("Statistics channel disconnected"); - last_statistics = Instant::now(); - statistics_bytes_read = 0; - } - - let data_end = leftover_bytes_in_buffer + bytes_read; - if bytes_read == 0 { - if leftover_bytes_in_buffer == 0 { - // We read no data and the previous loop did consume all data - // Nothing to do here, closing connection - break; - } - - // No new data from socket, read to the end and everything should be fine - leftover_bytes_in_buffer = 0; - } else { - // We have read some data, process it - - // We need to zero the PARSER_LOOKAHEAD bytes, so the parser does not detect any command left over from a previous loop iteration - for i in &mut buffer[data_end..data_end + PARSER_LOOKAHEAD] { - *i = 0; - } - - parser_state = parse_pixelflut_commands( - &buffer[..data_end + PARSER_LOOKAHEAD], - &fb, - &mut stream, - parser_state, - ) - .await; - - // IMPORTANT: We have to subtract 1 here, as e.g. we have "PX 0 0\n" data_end is 7 and parser_state.last_byte_parsed is 6. - // This happens, because last_byte_parsed is an index starting at 0, so index 6 is from an array of length 7 - leftover_bytes_in_buffer = data_end - parser_state.last_byte_parsed() - 1; - - // There is no need to leave anything longer than a command can take - // This prevents malicious clients from sending gibberish and the buffer not getting drained - leftover_bytes_in_buffer = min(leftover_bytes_in_buffer, PARSER_LOOKAHEAD); - } - - if leftover_bytes_in_buffer > 0 { - // We need to move the leftover bytes to the beginning of the buffer so that the next loop iteration con work on them - buffer.copy_within( - parser_state.last_byte_parsed() + 1 - ..parser_state.last_byte_parsed() + 1 + leftover_bytes_in_buffer, - 0, - ); - } - } - - statistics_tx - .send(StatisticsEvent::ConnectionClosed { ip }) - .await - .expect("Statistics channel disconnected"); -} - -/// TODO: Switch to official ip.to_canonical() method when it is stable. **If** it gets stable sometime ;) -/// See -fn ip_to_canonical(ip: IpAddr) -> IpAddr { - match ip { - IpAddr::V4(_) => ip, - IpAddr::V6(v6) => match v6.octets() { - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, a, b, c, d] => { - IpAddr::V4(Ipv4Addr::new(a, b, c, d)) - } - _ => ip, - }, - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::test::helpers::MockTcpStream; - use rstest::{fixture, rstest}; - use std::time::Duration; - use tokio::sync::mpsc::{self, Receiver}; - - #[fixture] - fn ip() -> IpAddr { - IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)) - } - - #[fixture] - fn fb() -> Arc { - Arc::new(FrameBuffer::new(1920, 1080)) - } - - #[fixture] - fn statistics_channel() -> (Sender, Receiver) { - mpsc::channel(10000) - } - - #[rstest] - #[timeout(Duration::from_secs(1))] - #[case("", "")] - #[case("\n", "")] - #[case("not a pixelflut command", "")] - #[case("not a pixelflut command with newline\n", "")] - #[case("SIZE", "SIZE 1920 1080\n")] - #[case("SIZE\n", "SIZE 1920 1080\n")] - #[case("SIZE\nSIZE\n", "SIZE 1920 1080\nSIZE 1920 1080\n")] - #[case("SIZE", "SIZE 1920 1080\n")] - #[case("HELP", std::str::from_utf8(crate::parser::HELP_TEXT).unwrap())] - #[case("HELP\n", std::str::from_utf8(crate::parser::HELP_TEXT).unwrap())] - #[case("bla bla bla\nSIZE\nblub\nbla", "SIZE 1920 1080\n")] - #[tokio::test] - async fn test_correct_responses_to_general_commands( - #[case] input: &str, - #[case] expected: &str, - ip: IpAddr, - fb: Arc, - statistics_channel: (Sender, Receiver), - ) { - let mut stream = MockTcpStream::from_input(input); - handle_connection(&mut stream, ip, fb, statistics_channel.0).await; - - assert_eq!(expected, stream.get_output()); - } - - #[rstest] - // Without alpha - #[case("PX 0 0 ffffff\nPX 0 0\n", "PX 0 0 ffffff\n")] - #[case("PX 0 0 abcdef\nPX 0 0\n", "PX 0 0 abcdef\n")] - #[case("PX 0 42 abcdef\nPX 0 42\n", "PX 0 42 abcdef\n")] - #[case("PX 42 0 abcdef\nPX 42 0\n", "PX 42 0 abcdef\n")] - // With alpha - #[case("PX 0 0 ffffff00\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 000000\n"} else {"PX 0 0 ffffff\n"})] - #[case("PX 0 0 ffffffff\nPX 0 0\n", "PX 0 0 ffffff\n")] - #[case("PX 0 1 abcdef00\nPX 0 1\n", if cfg!(feature = "alpha") {"PX 0 1 000000\n"} else {"PX 0 1 abcdef\n"})] - #[case("PX 1 0 abcdefff\nPX 1 0\n", "PX 1 0 abcdef\n")] - #[case("PX 0 0 ffffff88\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 888888\n"} else {"PX 0 0 ffffff\n"})] - #[case("PX 0 0 ffffff11\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 111111\n"} else {"PX 0 0 ffffff\n"})] - #[case("PX 0 0 abcdef80\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 556677\n"} else {"PX 0 0 abcdef\n"})] - // 0xab = 171, 0x88 = 136 - // (171 * 136) / 255 = 91 = 0x5b - #[case("PX 0 0 abcdef88\nPX 0 0\n", if cfg!(feature = "alpha") {"PX 0 0 5b6d7f\n"} else {"PX 0 0 abcdef\n"})] - // Short commands - #[case("PX 0 0 00\nPX 0 0\n", "PX 0 0 000000\n")] - #[case("PX 0 0 ff\nPX 0 0\n", "PX 0 0 ffffff\n")] - #[case("PX 0 1 12\nPX 0 1\n", "PX 0 1 121212\n")] - #[case("PX 0 1 34\nPX 0 1\n", "PX 0 1 343434\n")] - // Tests invalid bounds - #[case("PX 9999 0 abcdef\nPX 9999 0\n", "")] // Parsable but outside screen size - #[case("PX 0 9999 abcdef\nPX 9999 0\n", "")] - #[case("PX 9999 9999 abcdef\nPX 9999 9999\n", "")] - #[case("PX 99999 0 abcdef\nPX 0 99999\n", "")] // Not even parsable because to many digits - #[case("PX 0 99999 abcdef\nPX 0 99999\n", "")] - #[case("PX 99999 99999 abcdef\nPX 99999 99999\n", "")] - // Test invalid inputs - #[case("PX 0 abcdef\nPX 0 0\n", "PX 0 0 000000\n")] - #[case("PX 0 1 2 abcdef\nPX 0 0\n", "PX 0 0 000000\n")] - #[case("PX -1 0 abcdef\nPX 0 0\n", "PX 0 0 000000\n")] - #[case("bla bla bla\nPX 0 0\n", "PX 0 0 000000\n")] - // Test offset - #[case( - "OFFSET 10 10\nPX 0 0 ffffff\nPX 0 0\nPX 42 42\n", - "PX 0 0 ffffff\nPX 42 42 000000\n" - )] // The get pixel result is also offseted - #[case("OFFSET 0 0\nPX 0 42 abcdef\nPX 0 42\n", "PX 0 42 abcdef\n")] - #[case( - "OFFSET 10 10\nPX 0 0 ffffff\nOFFSET 0 0\nPX 0 0\nPX 10 10\n", - "PX 0 0 000000\nPX 10 10 ffffff\n" - )] - #[tokio::test] - async fn test_setting_pixel( - #[case] input: &str, - #[case] expected: &str, - ip: IpAddr, - fb: Arc, - statistics_channel: (Sender, Receiver), - ) { - let mut stream = MockTcpStream::from_input(input); - handle_connection(&mut stream, ip, fb, statistics_channel.0).await; - - assert_eq!(expected, stream.get_output()); - } - - #[rstest] - #[case("PX 0 0 aaaaaa\n")] - #[case("PX 0 0 aa\n")] - #[tokio::test] - async fn test_safe( - #[case] input: &str, - ip: IpAddr, - fb: Arc, - statistics_channel: (Sender, Receiver), - ) { - let mut stream = MockTcpStream::from_input(input); - handle_connection(&mut stream, ip, fb.clone(), statistics_channel.0).await; - // Test if it panics - assert_eq!(fb.get(0, 0).unwrap() & 0x00ff_ffff, 0xaaaaaa); - } - - #[rstest] - #[case(5, 5, 0, 0)] - #[case(6, 6, 0, 0)] - #[case(7, 7, 0, 0)] - #[case(8, 8, 0, 0)] - #[case(9, 9, 0, 0)] - #[case(10, 10, 0, 0)] - #[case(10, 10, 100, 200)] - #[case(10, 10, 510, 520)] - #[case(100, 100, 0, 0)] - #[case(100, 100, 300, 400)] - #[case(479, 361, 721, 391)] - #[case(500, 500, 0, 0)] - #[case(500, 500, 300, 400)] - #[case(fb().get_width(), fb().get_height(), 0, 0)] - #[case(fb().get_width() - 1, fb().get_height() - 1, 1, 1)] - #[tokio::test] - async fn test_drawing_rect( - #[case] width: usize, - #[case] height: usize, - #[case] offset_x: usize, - #[case] offset_y: usize, - ip: IpAddr, - fb: Arc, - statistics_channel: (Sender, Receiver), - ) { - let mut color: u32 = 0; - let mut fill_commands = String::new(); - let mut read_commands = String::new(); - let mut combined_commands = String::new(); - let mut combined_commands_expected = String::new(); - let mut read_other_pixels_commands = String::new(); - let mut read_other_pixels_commands_expected = String::new(); - - for x in 0..fb.get_width() { - for y in 0..height { - // Inside the rect - if x >= offset_x && x <= offset_x + width && y >= offset_y && y <= offset_y + height - { - fill_commands += &format!("PX {x} {y} {color:06x}\n"); - read_commands += &format!("PX {x} {y}\n"); - - color += 1; // Use another color for the next test case - combined_commands += &format!("PX {x} {y} {color:06x}\nPX {x} {y}\n"); - combined_commands_expected += &format!("PX {x} {y} {color:06x}\n"); - - color += 1; - } else { - // Non touched pixels must remain black - read_other_pixels_commands += &format!("PX {x} {y}\n"); - read_other_pixels_commands_expected += &format!("PX {x} {y} 000000\n"); - } - } - } - - // Color the pixels - let mut stream = MockTcpStream::from_input(&fill_commands); - handle_connection( - &mut stream, - ip, - Arc::clone(&fb), - statistics_channel.0.clone(), - ) - .await; - assert_eq!("", stream.get_output()); - - // Read the pixels again - let mut stream = MockTcpStream::from_input(&read_commands); - handle_connection( - &mut stream, - ip, - Arc::clone(&fb), - statistics_channel.0.clone(), - ) - .await; - assert_eq!(fill_commands, stream.get_output()); - - // We can also do coloring and reading in a single connection - let mut stream = MockTcpStream::from_input(&combined_commands); - handle_connection( - &mut stream, - ip, - Arc::clone(&fb), - statistics_channel.0.clone(), - ) - .await; - assert_eq!(combined_commands_expected, stream.get_output()); - - // Check that nothing else was colored - let mut stream = MockTcpStream::from_input(&read_other_pixels_commands); - handle_connection( - &mut stream, - ip, - Arc::clone(&fb), - statistics_channel.0.clone(), - ) - .await; - assert_eq!(read_other_pixels_commands_expected, stream.get_output()); - } -} diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index 7dafa37..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,318 +0,0 @@ -use crate::framebuffer::FrameBuffer; -use const_format::formatcp; -use std::simd::{num::SimdUint, u32x8, Simd}; -use std::slice::from_raw_parts; -use std::sync::Arc; -use tokio::io::AsyncWriteExt; - -#[cfg(target_arch = "x86_64")] -use log::{info, warn}; - -pub const PARSER_LOOKAHEAD: usize = "PX 1234 1234 rrggbbaa\n".len(); // Longest possible command -pub const HELP_TEXT: &[u8] = formatcp!("\ -Pixelflut server powered by breakwater https://github.com/sbernauer/breakwater -Available commands: -HELP: Show this help -PX x y rrggbb: Color the pixel (x,y) with the given hexadecimal color rrggbb -{} -PX x y gg: Color the pixel (x,y) with the hexadecimal color gggggg. Basically this is the same as the other commands, but is a more efficient way of filling white, black or gray areas -PX x y: Get the color value of the pixel (x,y) -SIZE: Get the size of the drawing surface, e.g. `SIZE 1920 1080` -OFFSET x y: Apply offset (x,y) to all further pixel draws on this connection. This can e.g. be used to pre-calculate an image/animation and simply use the OFFSET command to move it around the screen without the need to re-calculate it -", -if cfg!(feature = "alpha") { - "PX x y rrggbbaa: Color the pixel (x,y) with the given hexadecimal color rrggbb and a transparency of aa, where ff means draw normally on top of the existing pixel and 00 means fully transparent (no change at all)" -} else { - "PX x y rrggbbaa: Color the pixel (x,y) with the given hexadecimal color rrggbb. The alpha part is discarded for performance reasons, as breakwater was compiled without the alpha feature" -} -).as_bytes(); - -#[derive(Clone, Default, Debug)] -pub struct ParserState { - connection_x_offset: usize, - connection_y_offset: usize, - last_byte_parsed: usize, -} - -impl ParserState { - pub fn last_byte_parsed(&self) -> usize { - self.last_byte_parsed - } -} - -const fn string_to_number(input: &[u8]) -> u64 { - (input[7] as u64) << 56 - | (input[6] as u64) << 48 - | (input[5] as u64) << 40 - | (input[4] as u64) << 32 - | (input[3] as u64) << 24 - | (input[2] as u64) << 16 - | (input[1] as u64) << 8 - | (input[0] as u64) -} - -/// Returns the offset (think of index in [u8]) of the last bytes of the last fully parsed command. -/// -/// TODO: Implement support for 16K (15360 × 8640). -/// Currently the parser only can read up to 4 digits of x or y coordinates. -/// If you buy me a big enough screen I will kindly implement this feature. -pub async fn parse_pixelflut_commands( - buffer: &[u8], - fb: &Arc, - mut stream: impl AsyncWriteExt + Unpin, - // We don't pass this as mutual reference but instead hand it around - I guess on the stack? - // I don't know what I'm doing, hoping for best performance anyway ;) - parser_state: ParserState, -) -> ParserState { - let mut last_byte_parsed = 0; - let mut connection_x_offset = parser_state.connection_x_offset; - let mut connection_y_offset = parser_state.connection_y_offset; - - let mut i = 0; // We can't use a for loop here because Rust don't lets use skip characters by incrementing i - let loop_end = buffer.len().saturating_sub(PARSER_LOOKAHEAD); // Let's extract the .len() call and the subtraction into it's own variable so we only compute it once - - while i < loop_end { - let current_command = unsafe { (buffer.as_ptr().add(i) as *const u64).read_unaligned() }; - if current_command & 0x00ff_ffff == string_to_number(b"PX \0\0\0\0\0") { - i += 3; - - let (mut x, mut y, present) = parse_pixel_coordinates(buffer.as_ptr(), &mut i); - - if present { - x += connection_x_offset; - y += connection_y_offset; - - // Separator between coordinates and color - if unsafe { *buffer.get_unchecked(i) } == b' ' { - i += 1; - - // TODO: Determine what clients use more: RGB, RGBA or gg variant. - // If RGBA is used more often move the RGB code below the RGBA code - - // Must be followed by 6 bytes RGB and newline or ... - if unsafe { *buffer.get_unchecked(i + 6) } == b'\n' { - last_byte_parsed = i + 6; - i += 7; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop - - let rgba: u32 = - simd_unhex(unsafe { from_raw_parts(buffer.as_ptr().add(i - 7), 8) }); - - fb.set(x, y, rgba & 0x00ff_ffff); - continue; - } - - // ... or must be followed by 8 bytes RGBA and newline - #[cfg(not(feature = "alpha"))] - if unsafe { *buffer.get_unchecked(i + 8) } == b'\n' { - last_byte_parsed = i + 8; - i += 9; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop - - let rgba: u32 = - simd_unhex(unsafe { from_raw_parts(buffer.as_ptr().add(i - 9), 8) }); - - fb.set(x, y, rgba & 0x00ff_ffff); - continue; - } - #[cfg(feature = "alpha")] - if unsafe { *buffer.get_unchecked(i + 8) } == b'\n' { - last_byte_parsed = i + 8; - i += 9; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop - - let rgba = - simd_unhex(unsafe { from_raw_parts(buffer.as_ptr().add(i - 9), 8) }); - - let alpha = (rgba >> 24) & 0xff; - - if alpha == 0 || x >= fb.get_width() || y >= fb.get_height() { - continue; - } - - let alpha_comp = 0xff - alpha; - let current = fb.get_unchecked(x, y); - let r = (rgba >> 16) & 0xff; - let g = (rgba >> 8) & 0xff; - let b = rgba & 0xff; - - let r: u32 = (((current >> 24) & 0xff) * alpha_comp + r * alpha) / 0xff; - let g: u32 = (((current >> 16) & 0xff) * alpha_comp + g * alpha) / 0xff; - let b: u32 = (((current >> 8) & 0xff) * alpha_comp + b * alpha) / 0xff; - - fb.set(x, y, r << 16 | g << 8 | b); - continue; - } - - // ... for the efficient/lazy clients - if unsafe { *buffer.get_unchecked(i + 2) } == b'\n' { - last_byte_parsed = i + 2; - i += 3; // We can advance one byte more than normal as we use continue and therefore not get incremented at the end of the loop - - let base = - simd_unhex(unsafe { from_raw_parts(buffer.as_ptr().add(i - 3), 8) }) - & 0xff; - - let rgba: u32 = base << 16 | base << 8 | base; - - fb.set(x, y, rgba); - - continue; - } - } - - // End of command to read Pixel value - if unsafe { *buffer.get_unchecked(i) } == b'\n' { - last_byte_parsed = i; - i += 1; - if let Some(rgb) = fb.get(x, y) { - match stream - .write_all( - format!( - "PX {} {} {:06x}\n", - // We don't want to return the actual (absolute) coordinates, the client should also get the result offseted - x - connection_x_offset, - y - connection_y_offset, - rgb.to_be() >> 8 - ) - .as_bytes(), - ) - .await - { - Ok(_) => (), - Err(_) => continue, - } - } - continue; - } - } - } else if current_command & 0x00ff_ffff_ffff_ffff == string_to_number(b"OFFSET \0\0") { - i += 7; - - let (x, y, present) = parse_pixel_coordinates(buffer.as_ptr(), &mut i); - - // End of command to set offset - if present && unsafe { *buffer.get_unchecked(i) } == b'\n' { - last_byte_parsed = i; - connection_x_offset = x; - connection_y_offset = y; - continue; - } - } else if current_command & 0xffff_ffff == string_to_number(b"SIZE\0\0\0\0") { - i += 4; - last_byte_parsed = i - 1; - - stream - .write_all(format!("SIZE {} {}\n", fb.get_width(), fb.get_height()).as_bytes()) - .await - .expect("Failed to write bytes to tcp socket"); - continue; - } else if current_command & 0xffff_ffff == string_to_number(b"HELP\0\0\0\0") { - i += 4; - last_byte_parsed = i - 1; - - stream - .write_all(HELP_TEXT) - .await - .expect("Failed to write bytes to tcp socket"); - continue; - } - - i += 1; - } - - ParserState { - connection_x_offset, - connection_y_offset, - last_byte_parsed, - } -} - -const SHIFT_PATTERN: Simd = u32x8::from_array([4, 0, 12, 8, 20, 16, 28, 24]); -const SIMD_6: Simd = u32x8::from_array([6; 8]); -const SIMD_F: Simd = u32x8::from_array([0xf; 8]); -const SIMD_9: Simd = u32x8::from_array([9; 8]); - -/// Parse a slice of 8 characters into a single u32 number -/// is undefined behavior for invalid characters -#[inline(always)] -fn simd_unhex(value: &[u8]) -> u32 { - #[cfg(debug_assertions)] - assert_eq!(value.len(), 8); - // Feel free to find a better, but fast, way, to cast all integers as u32 - let input = unsafe { - u32x8::from_array([ - *value.get_unchecked(0) as u32, - *value.get_unchecked(1) as u32, - *value.get_unchecked(2) as u32, - *value.get_unchecked(3) as u32, - *value.get_unchecked(4) as u32, - *value.get_unchecked(5) as u32, - *value.get_unchecked(6) as u32, - *value.get_unchecked(7) as u32, - ]) - }; - // Heavily inspired by https://github.com/nervosnetwork/faster-hex/blob/a4c06b387ddeeea311c9e84a3adcaf01015cf40e/src/decode.rs#L80 - let sr6 = input >> SIMD_6; - let and15 = input & SIMD_F; - let mul = sr6 * SIMD_9; - let hexed = and15 + mul; - let shifted = hexed << SHIFT_PATTERN; - shifted.reduce_or() -} - -pub fn check_cpu_support() { - #[cfg(target_arch = "x86_64")] - { - if !is_x86_feature_detected!("avx2") { - warn!("Your CPU does not support AVX2. Consider using a CPU supporting AVX2 for best performance"); - } else if !is_x86_feature_detected!("avx") { - warn!("Your CPU does not support AVX. Consider using a CPU supporting AVX2 (or at least AVX) for best performance"); - } else { - // At this point the CPU should support AVX und AVX2 - // Warn the user when he has compiled breakwater without the needed target features - if cfg!(all(target_feature = "avx", target_feature = "avx2")) { - info!("Using AVX and AVX2 support"); - } else { - warn!("Your CPU does support AVX and AVX2, but you have not enabled avx and avx2 support. Please re-compile using RUSTFLAGS='-C target-cpu=native' cargo build --release`"); - } - } - } -} - -#[inline(always)] -fn parse_coordinate(buffer: *const u8, current_index: &mut usize) -> (usize, bool) { - let digits = unsafe { (buffer.add(*current_index) as *const usize).read_unaligned() }; - - let mut result = 0; - let mut visited = false; - // The compiler will unroll this loop, but this way, it is more maintainable - for pos in 0..4 { - let digit = (digits >> (pos * 8)) & 0xff; - if digit >= b'0' as usize && digit <= b'9' as usize { - result = 10 * result + digit - b'0' as usize; - *current_index += 1; - visited = true; - } else { - break; - } - } - - (result, visited) -} - -#[inline(always)] -fn parse_pixel_coordinates(buffer: *const u8, current_index: &mut usize) -> (usize, usize, bool) { - let (x, x_visited) = parse_coordinate(buffer, current_index); - *current_index += 1; - let (y, y_visited) = parse_coordinate(buffer, current_index); - (x, y, x_visited && y_visited) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_from_hex_char() { - assert_eq!(simd_unhex(b"01234567"), 0x67452301); - assert_eq!(simd_unhex(b"fedcba98"), 0x98badcfe); - } -} diff --git a/src/prometheus_exporter.rs b/src/prometheus_exporter.rs deleted file mode 100644 index 45feb76..0000000 --- a/src/prometheus_exporter.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::net::SocketAddr; - -use prometheus_exporter::prometheus::{ - register_int_gauge, register_int_gauge_vec, IntGauge, IntGaugeVec, -}; -use tokio::sync::broadcast; - -use crate::statistics::StatisticsInformationEvent; - -pub struct PrometheusExporter { - listen_addr: SocketAddr, - statistics_information_rx: broadcast::Receiver, - - // Prometheus metrics - metric_ips: IntGauge, - metric_legacy_ips: IntGauge, - metric_frame: IntGauge, - metric_statistic_events: IntGauge, - - metric_connections_for_ip: IntGaugeVec, - metric_bytes_for_ip: IntGaugeVec, -} - -impl PrometheusExporter { - pub fn new( - listen_addr: &str, - statistics_information_rx: broadcast::Receiver, - ) -> Self { - let listen_addr = listen_addr.parse().unwrap_or_else(|_| { - panic!("Failed to parse prometheus listen address: {listen_addr}",) - }); - PrometheusExporter { - listen_addr, - statistics_information_rx, - metric_ips: register_int_gauge!("breakwater_ips", "Total number of IPs connected") - .unwrap(), - metric_legacy_ips: register_int_gauge!( - "breakwater_legacy_ips", - "Total number of legacy (v4) IPs connected" - ) - .unwrap(), - metric_frame: register_int_gauge!("breakwater_frame", "Frame number of the VNC server") - .unwrap(), - metric_statistic_events: register_int_gauge!( - "breakwater_statistic_events", - "Number of statistics events send internally" - ) - .unwrap(), - metric_connections_for_ip: register_int_gauge_vec!( - "breakwater_connections", - "Number of client connections per IP address", - &["ip"] - ) - .unwrap(), - metric_bytes_for_ip: register_int_gauge_vec!( - "breakwater_bytes", - "Number of bytes received", - &["ip"] - ) - .unwrap(), - } - } - - pub async fn run(&mut self) { - prometheus_exporter::start(self.listen_addr).expect("Failed to start prometheus exporter"); - while let Ok(event) = self.statistics_information_rx.recv().await { - self.metric_ips.set(event.ips as i64); - self.metric_legacy_ips.set(event.legacy_ips as i64); - self.metric_frame.set(event.frame as i64); - self.metric_statistic_events - .set(event.statistic_events as i64); - - // When clients drop a connection the item will be missing in `event.connections_for_ip, - // but would stay forever in the Prometheus metric - self.metric_connections_for_ip.reset(); - event - .connections_for_ip - .iter() - .for_each(|(ip, connections)| { - self.metric_connections_for_ip - .with_label_values(&[&ip.to_string()]) - .set(*connections as i64) - }); - self.metric_bytes_for_ip.reset(); - event.bytes_for_ip.iter().for_each(|(ip, bytes)| { - self.metric_bytes_for_ip - .with_label_values(&[&ip.to_string()]) - .set(*bytes as i64) - }); - } - } -} diff --git a/src/test/helpers/pixelflut_commands.rs b/src/test/helpers/pixelflut_commands.rs deleted file mode 100644 index 3aee60e..0000000 --- a/src/test/helpers/pixelflut_commands.rs +++ /dev/null @@ -1,23 +0,0 @@ -pub fn get_commands_to_draw_rect(width: usize, height: usize, color: u32) -> Vec { - let mut draw_commands = Vec::new(); - - for x in 0..width { - for y in 0..height { - draw_commands.push(format!("PX {x} {y} {color:06x}\n")); - } - } - - draw_commands -} - -pub fn get_commands_to_read_rect(width: usize, height: usize) -> String { - let mut read_commands = String::new(); - - for x in 0..width { - for y in 0..height { - read_commands += &format!("PX {x} {y}\n"); - } - } - - read_commands -} diff --git a/src/test/mod.rs b/src/test/mod.rs deleted file mode 100644 index 1630fab..0000000 --- a/src/test/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod helpers;