From 66c996e6ae24ae496aa6b7ed07c85dce2b106d13 Mon Sep 17 00:00:00 2001 From: main Date: Sat, 25 Apr 2026 13:27:32 -0400 Subject: Release memview 1.0.0 --- .gitignore | 1 + .ignore | 1 + AGENTS.md | 20 + Cargo.lock | 1118 ++++++++++++++++++++++++ Cargo.toml | 129 +++ check.py | 233 +++++ clippy.toml | 5 + crates/memview/Cargo.toml | 28 + crates/memview/README.md | 23 + crates/memview/src/app.rs | 1774 ++++++++++++++++++++++++++++++++++++++ crates/memview/src/app/rows.rs | 484 +++++++++++ crates/memview/src/app/tests.rs | 390 +++++++++ crates/memview/src/app/worker.rs | 368 ++++++++ crates/memview/src/main.rs | 160 ++++ crates/memview/src/model.rs | 480 +++++++++++ crates/memview/src/nav.rs | 217 +++++ crates/memview/src/probe.rs | 1417 ++++++++++++++++++++++++++++++ crates/memview/src/search.rs | 130 +++ crates/memview/src/ui.rs | 1076 +++++++++++++++++++++++ rust-toolchain.toml | 4 + 20 files changed, 8058 insertions(+) create mode 100644 .gitignore create mode 100644 .ignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100755 check.py create mode 100644 clippy.toml create mode 100644 crates/memview/Cargo.toml create mode 100644 crates/memview/README.md create mode 100644 crates/memview/src/app.rs create mode 100644 crates/memview/src/app/rows.rs create mode 100644 crates/memview/src/app/tests.rs create mode 100644 crates/memview/src/app/worker.rs create mode 100644 crates/memview/src/main.rs create mode 100644 crates/memview/src/model.rs create mode 100644 crates/memview/src/nav.rs create mode 100644 crates/memview/src/probe.rs create mode 100644 crates/memview/src/search.rs create mode 100644 crates/memview/src/ui.rs create mode 100644 rust-toolchain.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0c1da24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# AGENTS.md + +Before meaningful Rust edits, read the [Rust Style Doctrine](/home/main/programming/projects/rust_starter/docs/rust-style-doctrine.md). +It intentionally rejects several pieces of conventional Rust advice; preserve +those inversions instead of translating them back into generic approachable +code. + +Local rules: + +- The only permitted child/subagent is `clippy_fixer`, and only for + `$clippy-fix`. Do not invoke any other agent kind. +- Prefer explicit domain types, enums, newtypes, and total transforms over + stringly state, bool flags, and ad-hoc tuples. +- Use `rust-analyzer` before textual search when you need definitions, + references, diagnostics, or refactors. +- Default to deletion over backward-compatibility shims when the task does not + explicitly require compatibility. +- Run `cargo fmt` after meaningful edits. +- Run `./check.py check` after meaningful local edits. Use `./check.py verify` + for a non-mutating CI-style gate. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b1eb701 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1118 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memview" +version = "1.0.0" +dependencies = [ + "clap", + "color-eyre", + "crossterm", + "ratatui", + "regex", + "rustix", + "uzers", + "walkdir", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uzers" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8275fb1afee25b4111d2dc8b5c505dbbc4afd0b990cb96deb2d88bff8be18d" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..60731f5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,129 @@ +[workspace] +members = ["crates/memview"] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +rust-version = "1.95" +version = "1.0.0" + +[workspace.lints.rust] +warnings = "deny" +elided_lifetimes_in_paths = "deny" +unexpected_cfgs = "deny" +unsafe_code = "deny" +unused_crate_dependencies = "warn" +unused_lifetimes = "deny" +unused_qualifications = "deny" +unused_results = "deny" +ambiguous_glob_imports = "allow" +ambiguous_glob_reexports = "allow" +hidden_glob_reexports = "allow" + +[workspace.lints.rustdoc] +bare_urls = "deny" +broken_intra_doc_links = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -2 } +pedantic = { level = "deny", priority = -1 } +cargo = { level = "warn", priority = -3 } + +dbg_macro = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unwrap_used = "deny" +allow_attributes_without_reason = "deny" + +cargo_common_metadata = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +multiple_crate_versions = "allow" + +enum_glob_use = "allow" +wildcard_imports = "allow" + +items_after_statements = "allow" +many_single_char_names = "allow" +match_same_arms = "allow" +module_name_repetitions = "allow" +similar_names = "allow" +struct_field_names = "allow" +too_many_arguments = "allow" +too_many_lines = "allow" +unnested_or_patterns = "allow" + +cast_lossless = "allow" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +float_cmp = "allow" +implicit_hasher = "allow" +manual_let_else = "allow" +map_unwrap_or = "allow" +uninlined_format_args = "allow" + +ignored_unit_patterns = "allow" +must_use_candidate = "allow" +needless_pass_by_value = "allow" +no_effect_underscore_binding = "allow" +redundant_closure_for_method_calls = "allow" +ref_option = "allow" +return_self_not_must_use = "allow" +trivially_copy_pass_by_ref = "allow" +unused_async = "allow" +used_underscore_binding = "allow" + +[workspace.metadata.rust-starter] +format_command = ["cargo", "fmt", "--all", "--check"] +clippy_command = [ + "cargo", + "clippy", + "--workspace", + "--all-targets", + "--all-features", +] +test_command = ["cargo", "test", "--workspace", "--all-targets", "--all-features"] +doc_command = ["cargo", "doc", "--workspace", "--all-features", "--no-deps"] +install_command = [ + "cargo", + "install", + "--path", + "crates/memview", + "--locked", + "--profile", + "release", +] +canonicalize_commands = [ + [ + "cargo", + "fix", + "--workspace", + "--all-targets", + "--all-features", + "--allow-dirty", + "--allow-staged", + "--allow-no-vcs", + ], + [ + "cargo", + "clippy", + "--fix", + "--workspace", + "--all-targets", + "--all-features", + "--allow-dirty", + "--allow-staged", + "--allow-no-vcs", + ], + ["cargo", "fmt", "--all"], +] + +[workspace.metadata.rust-starter.source_files] +max_lines = 1800 +include = ["*.rs", "**/*.rs"] +exclude = [] diff --git a/check.py b/check.py new file mode 100755 index 0000000..98c773c --- /dev/null +++ b/check.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import subprocess +import tomllib +from dataclasses import dataclass +from pathlib import Path +from pathlib import PurePosixPath + + +ROOT = Path(__file__).resolve().parent +WORKSPACE_MANIFEST = ROOT / "Cargo.toml" +DEFAULT_MAX_SOURCE_FILE_LINES = 2500 +DEFAULT_SOURCE_FILE_INCLUDE = ("*.rs", "**/*.rs") +IGNORED_SOURCE_DIRS = frozenset( + {".direnv", ".git", ".hg", ".jj", ".svn", "__pycache__", "node_modules", "target", "vendor"} +) +Command = tuple[str, ...] +CommandSequence = tuple[Command, ...] + + +@dataclass(frozen=True, slots=True) +class SourceFilePolicy: + max_lines: int + include: tuple[str, ...] + exclude: tuple[str, ...] + + +def load_workspace_metadata() -> dict[str, object]: + workspace = tomllib.loads(WORKSPACE_MANIFEST.read_text(encoding="utf-8")) + return workspace["workspace"]["metadata"]["rust-starter"] + + +def load_command(value: object, *, key_path: str) -> Command: + if not isinstance(value, list) or not value or not all(isinstance(part, str) and part for part in value): + raise SystemExit(f"[check] invalid {key_path}: expected a non-empty string list") + return tuple(value) + + +def load_command_sequence(value: object, *, key_path: str) -> CommandSequence: + if not isinstance(value, list) or not value: + raise SystemExit(f"[check] invalid {key_path}: expected a non-empty list of commands") + + commands: list[Command] = [] + for index, command in enumerate(value, start=1): + commands.append(load_command(command, key_path=f"{key_path}[{index}]")) + return tuple(commands) + + +def load_commands(metadata: dict[str, object]) -> dict[str, Command | CommandSequence]: + commands: dict[str, Command | CommandSequence] = { + "format_command": load_command( + metadata.get("format_command"), + key_path="workspace.metadata.rust-starter.format_command", + ), + "clippy_command": load_command( + metadata.get("clippy_command"), + key_path="workspace.metadata.rust-starter.clippy_command", + ), + "test_command": load_command( + metadata.get("test_command"), + key_path="workspace.metadata.rust-starter.test_command", + ), + "canonicalize_commands": load_command_sequence( + metadata.get("canonicalize_commands"), + key_path="workspace.metadata.rust-starter.canonicalize_commands", + ), + } + + raw_doc_command = metadata.get("doc_command") + if raw_doc_command is not None: + commands["doc_command"] = load_command( + raw_doc_command, + key_path="workspace.metadata.rust-starter.doc_command", + ) + raw_install_command = metadata.get("install_command") + if raw_install_command is not None: + commands["install_command"] = load_command( + raw_install_command, + key_path="workspace.metadata.rust-starter.install_command", + ) + return commands + + +def load_patterns( + value: object, + *, + default: tuple[str, ...], + key_path: str, + allow_empty: bool, +) -> tuple[str, ...]: + if value is None: + return default + if not isinstance(value, list) or not all(isinstance(pattern, str) and pattern for pattern in value): + raise SystemExit(f"[check] invalid {key_path}: expected a string list") + if not allow_empty and not value: + raise SystemExit(f"[check] invalid {key_path}: expected at least one pattern") + return tuple(value) + + +def load_source_file_policy(metadata: dict[str, object]) -> SourceFilePolicy: + raw_policy = metadata.get("source_files") + if raw_policy is None: + return SourceFilePolicy(DEFAULT_MAX_SOURCE_FILE_LINES, DEFAULT_SOURCE_FILE_INCLUDE, ()) + if not isinstance(raw_policy, dict): + raise SystemExit("[check] invalid workspace.metadata.rust-starter.source_files: expected a table") + + max_lines = raw_policy.get("max_lines", DEFAULT_MAX_SOURCE_FILE_LINES) + if not isinstance(max_lines, int) or max_lines <= 0: + raise SystemExit( + "[check] invalid workspace.metadata.rust-starter.source_files.max_lines: expected a positive integer" + ) + + include = load_patterns( + raw_policy.get("include"), + default=DEFAULT_SOURCE_FILE_INCLUDE, + key_path="workspace.metadata.rust-starter.source_files.include", + allow_empty=False, + ) + exclude = load_patterns( + raw_policy.get("exclude"), + default=(), + key_path="workspace.metadata.rust-starter.source_files.exclude", + allow_empty=True, + ) + return SourceFilePolicy(max_lines, include, exclude) + + +def run(name: str, argv: Command) -> None: + print(f"[check] {name}: {' '.join(argv)}", flush=True) + proc = subprocess.run(argv, cwd=ROOT) + if proc.returncode != 0: + raise SystemExit(proc.returncode) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Thin Rust starter check runner") + parser.add_argument( + "mode", + nargs="?", + choices=("check", "verify", "deep", "fix", "canon", "install"), + default="check", + help=( + "Run canonicalization plus the fast gate, run a non-mutating verification gate, " + "include docs for the deep gate, run only canonicalization, or install locally." + ), + ) + return parser.parse_args() + + +def run_command_sequence(name: str, commands: CommandSequence) -> None: + for index, command in enumerate(commands, start=1): + run(f"{name}.{index}", command) + + +def matches_pattern(path: PurePosixPath, pattern: str) -> bool: + if path.match(pattern): + return True + prefix = "**/" + return pattern.startswith(prefix) and path.match(pattern.removeprefix(prefix)) + + +def iter_source_files(policy: SourceFilePolicy) -> list[Path]: + paths: list[Path] = [] + for current_root, dirnames, filenames in os.walk(ROOT): + dirnames[:] = sorted(name for name in dirnames if name not in IGNORED_SOURCE_DIRS) + current = Path(current_root) + for filename in filenames: + path = current / filename + relative_path = PurePosixPath(path.relative_to(ROOT).as_posix()) + if not any(matches_pattern(relative_path, pattern) for pattern in policy.include): + continue + if any(matches_pattern(relative_path, pattern) for pattern in policy.exclude): + continue + paths.append(path) + return sorted(paths) + + +def line_count(path: Path) -> int: + return len(path.read_text(encoding="utf-8").splitlines()) + + +def enforce_source_file_policy(policy: SourceFilePolicy) -> None: + paths = iter_source_files(policy) + print(f"[check] source-files: max {policy.max_lines} lines", flush=True) + violations: list[tuple[str, int]] = [] + for path in paths: + lines = line_count(path) + if lines > policy.max_lines: + violations.append((path.relative_to(ROOT).as_posix(), lines)) + if not violations: + return + + print( + f"[check] source-files: {len(violations)} file(s) exceed the configured limit", + flush=True, + ) + for relative_path, lines in violations: + print(f"[check] source-files: {relative_path}: {lines} lines", flush=True) + raise SystemExit(1) + + +def main() -> None: + metadata = load_workspace_metadata() + commands = load_commands(metadata) + source_file_policy = load_source_file_policy(metadata) + args = parse_args() + + if args.mode in {"fix", "canon"}: + run_command_sequence("canonicalize", commands["canonicalize_commands"]) + return + + enforce_source_file_policy(source_file_policy) + if args.mode != "verify": + run_command_sequence("canonicalize", commands["canonicalize_commands"]) + + run("fmt", commands["format_command"]) + run("clippy", commands["clippy_command"]) + run("test", commands["test_command"]) + + if args.mode == "deep" and "doc_command" in commands: + run("doc", commands["doc_command"]) + if args.mode == "install": + run("install", commands["install_command"]) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + raise SystemExit(130) diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..b3be953 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +# Keep this file tiny. Do not move the repo-wide allow/deny architecture here. + +allow-expect-in-tests = true +allow-unwrap-in-tests = true +allow-panic-in-tests = true diff --git a/crates/memview/Cargo.toml b/crates/memview/Cargo.toml new file mode 100644 index 0000000..a834f25 --- /dev/null +++ b/crates/memview/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "memview" +description = "Linux-only ncdu-like TUI for attributing RAM across processes, tmpfs, shm, and kernel counters" +edition.workspace = true +keywords = ["linux", "memory", "procfs", "tmpfs", "tui"] +categories = ["command-line-utilities"] +license.workspace = true +readme = "README.md" +repository = "https://git.swarm.moe/memview" +rust-version.workspace = true +version.workspace = true + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } +color-eyre = "0.6.5" +crossterm = "0.29.0" +ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] } +regex = "1.12.2" +rustix = { version = "1.1.4", features = ["process"] } +uzers = "0.12.2" +walkdir = "2.5.0" + +[lints] +workspace = true + +[package.metadata.docs.rs] +default-target = "x86_64-unknown-linux-gnu" +targets = ["x86_64-unknown-linux-gnu"] diff --git a/crates/memview/README.md b/crates/memview/README.md new file mode 100644 index 0000000..3ba685a --- /dev/null +++ b/crates/memview/README.md @@ -0,0 +1,23 @@ +# memview + +`memview` is a Linux-only terminal UI for attributing RAM usage across the +surfaces that usually make totals feel impossible to reconcile: processes, +tmpfs, shared mappings, SysV shm, and kernel counters. + +It is intentionally not cross-platform. The tool reads Linux `/proc`, tmpfs +mount metadata, smaps on demand, and pidfd process-control surfaces. + +## Install + +```bash +cargo install --path crates/memview --locked --profile release +``` + +## Use + +```bash +memview +``` + +The default pane is `Processes`. Press `?` inside the TUI for global and +pane-specific controls. diff --git a/crates/memview/src/app.rs b/crates/memview/src/app.rs new file mode 100644 index 0000000..f52afa5 --- /dev/null +++ b/crates/memview/src/app.rs @@ -0,0 +1,1774 @@ +use crate::model::{ + Bytes, LedgerState, Meminfo, Metric, ObjectKind, ObjectUsage, Pid, ProcessNode, SharedObject, + Snapshot, TmpfsMount, TmpfsNode, TmpfsNodeKind, +}; +pub use crate::nav::{Hotkey, HotkeySections, Tab}; +use crate::probe; +use crate::search::{Search, SearchDraft, SearchRole, SearchSummary}; +use color_eyre::eyre::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use rustix::fd::OwnedFd; +use rustix::process::{Pid as KernelPid, PidfdFlags, Signal, pidfd_open, pidfd_send_signal}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +mod rows; +mod worker; +use rows::{build_process_rows, build_shared_rows, build_tmpfs_rows}; +pub use worker::{WorkerCommand, WorkerEvent, spawn_worker}; + +const KILL_ARMING_DELAY: Duration = Duration::from_secs(2); +const TERMINAL_FRAME_ROWS: u16 = 9; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProcessScope { + SelfOnly, + SelfAndChildren, +} + +impl ProcessScope { + #[must_use] + pub fn next(self) -> Self { + match self { + Self::SelfOnly => Self::SelfAndChildren, + Self::SelfAndChildren => Self::SelfOnly, + } + } + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::SelfOnly => "self", + Self::SelfAndChildren => "self+children", + } + } + + #[must_use] + pub fn rollup(self, node: &ProcessNode) -> crate::model::MemoryRollup { + match self { + Self::SelfOnly => node.rollup, + Self::SelfAndChildren => node.subtree, + } + } +} + +#[derive(Clone, Debug)] +pub struct FlatProcessRow { + pub index: usize, + pub pid: Pid, + pub depth: usize, + pub fold: RowFold, + pub search: SearchRole, +} + +#[derive(Clone, Debug)] +pub struct FlatTmpfsRow { + pub mount_index: usize, + pub path: PathBuf, + pub name: String, + pub kind: TmpfsNodeKind, + pub allocated: Bytes, + pub logical: Bytes, + pub depth: usize, + pub fold: RowFold, + pub search: SearchRole, +} + +#[derive(Clone, Debug)] +pub struct FlatSharedRow { + pub index: usize, + key: (ObjectKind, String), + pub search: SearchRole, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RowFold { + Leaf, + Expanded, + Collapsed, +} + +impl RowFold { + #[must_use] + fn is_collapsed(self) -> bool { + self == Self::Collapsed + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RowIndex(usize); + +impl RowIndex { + #[must_use] + fn new(index: usize) -> Self { + Self(index) + } + + #[must_use] + pub fn get(self) -> usize { + self.0 + } +} + +pub trait IdentifiedRow { + type Key: Clone + Ord; + + fn key(&self) -> &Self::Key; +} + +impl IdentifiedRow for FlatProcessRow { + type Key = Pid; + + fn key(&self) -> &Self::Key { + &self.pid + } +} + +impl IdentifiedRow for FlatTmpfsRow { + type Key = PathBuf; + + fn key(&self) -> &Self::Key { + &self.path + } +} + +impl IdentifiedRow for FlatSharedRow { + type Key = (ObjectKind, String); + + fn key(&self) -> &Self::Key { + &self.key + } +} + +#[derive(Clone, Debug)] +pub struct PaneRows { + rows: Vec, + by_key: BTreeMap, + selected: Option, +} + +impl Default for PaneRows { + fn default() -> Self { + Self { + rows: Vec::new(), + by_key: BTreeMap::new(), + selected: None, + } + } +} + +impl PaneRows { + #[must_use] + pub fn rows(&self) -> &[Row] { + &self.rows + } + + #[must_use] + pub fn selected(&self) -> Option<&Row> { + self.selected.and_then(|index| self.rows.get(index.get())) + } + + #[must_use] + pub fn selected_index(&self) -> usize { + self.selected.map_or(0, RowIndex::get) + } + + fn selected_slot(&self) -> Option { + self.selected + } + + fn selected_key(&self) -> Option { + self.selected().map(|row| row.key().clone()) + } + + fn install(&mut self, rows: Vec) { + self.install_prefer(rows, self.selected_key()); + } + + fn install_pinned_to_top(&mut self, rows: Vec) { + self.install_prefer(rows, None); + } + + fn install_prefer(&mut self, rows: Vec, preferred: Option) { + let by_key = rows + .iter() + .enumerate() + .map(|(index, row)| (row.key().clone(), RowIndex::new(index))) + .collect::>(); + let selected = preferred + .and_then(|key| by_key.get(&key).copied()) + .or_else(|| (!rows.is_empty()).then_some(RowIndex::new(0))); + + self.rows = rows; + self.by_key = by_key; + self.selected = selected; + } + + fn select_clamped(&mut self, index: RowIndex) -> bool { + if self.rows.is_empty() { + let changed = self.selected.is_some(); + self.selected = None; + return changed; + } + let next = RowIndex::new(index.get().min(self.rows.len() - 1)); + let changed = self.selected != Some(next); + self.selected = Some(next); + changed + } + + fn move_by(&mut self, delta: isize) -> bool { + let Some(current) = self.selected else { + return false; + }; + if self.rows.is_empty() { + return false; + } + let next = RowIndex::new( + (current.get() as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize, + ); + let changed = current != next; + self.selected = Some(next); + changed + } + + fn select_edge(&mut self, edge: RowEdge) -> bool { + let Some(next) = edge.index(self.rows.len()) else { + return false; + }; + let changed = self.selected != Some(next); + self.selected = Some(next); + changed + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RowEdge { + First, + Last, +} + +impl RowEdge { + #[must_use] + fn index(self, len: usize) -> Option { + match self { + Self::First => (len > 0).then_some(RowIndex::new(0)), + Self::Last => len.checked_sub(1).map(RowIndex::new), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PageRows(usize); + +impl Default for PageRows { + fn default() -> Self { + Self(1) + } +} + +impl PageRows { + #[must_use] + fn from_terminal_height(height: u16) -> Self { + Self(usize::from( + height.saturating_sub(TERMINAL_FRAME_ROWS).max(1), + )) + } + + #[must_use] + fn delta(self, direction: PageDirection) -> isize { + let rows = self.0.min(isize::MAX as usize) as isize; + match direction { + PageDirection::Up => -rows, + PageDirection::Down => rows, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PageDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum SelectionCustody { + #[default] + SystemOwned, + UserOwned, +} + +impl SelectionCustody { + #[must_use] + fn preserves_anchor(self) -> bool { + self == Self::UserOwned + } + + fn seize(&mut self) { + *self = Self::UserOwned; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DeMinimis { + threshold: Bytes, +} + +impl DeMinimis { + #[must_use] + fn from_largest_non_root(system_total: Bytes, largest_non_root: Bytes) -> Self { + Self { + threshold: pct(system_total, 1).min(pct(largest_non_root, 3)), + } + } + + #[must_use] + fn folds(self, value: Bytes) -> bool { + self.threshold.0 > 0 && value < self.threshold + } +} + +fn pct(value: Bytes, percent: u64) -> Bytes { + Bytes(((u128::from(value.0) * u128::from(percent)) / 100).min(u128::from(u64::MAX)) as u64) +} + +#[derive(Debug)] +struct FoldPolicy<'a, Key> { + collapsed: &'a BTreeSet, + expanded: &'a BTreeSet, + de_minimis: DeMinimis, +} + +impl FoldPolicy<'_, Key> { + #[must_use] + fn row_fold(&self, key: &Key, depth: usize, has_children: bool, total: Bytes) -> RowFold { + if !has_children { + RowFold::Leaf + } else if self.collapsed.contains(key) { + RowFold::Collapsed + } else if self.expanded.contains(key) { + RowFold::Expanded + } else if depth > 0 && self.de_minimis.folds(total) { + RowFold::Collapsed + } else { + RowFold::Expanded + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum KeySequence { + #[default] + Root, + G, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SequenceResolution { + Unmatched(KeyEvent), + Pending, + Cancelled, + Command(SequenceCommand), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SequenceCommand { + FirstRow, + LastRow, +} + +impl KeySequence { + fn resolve(&mut self, key: KeyEvent) -> SequenceResolution { + match (*self, plain_char(key)) { + (Self::Root, Some('g')) => { + *self = Self::G; + SequenceResolution::Pending + } + (Self::Root, Some('G')) => SequenceResolution::Command(SequenceCommand::LastRow), + (Self::Root, _) => SequenceResolution::Unmatched(key), + (Self::G, Some('g')) => { + *self = Self::Root; + SequenceResolution::Command(SequenceCommand::FirstRow) + } + (Self::G, _) => { + *self = Self::Root; + SequenceResolution::Cancelled + } + } + } +} + +fn plain_char(key: KeyEvent) -> Option { + if key.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) { + return None; + } + match key.code { + KeyCode::Char(character) => Some(character), + KeyCode::Esc => Some('\u{1b}'), + _ => None, + } +} + +pub struct App { + pub tab: Tab, + pub metric: Metric, + pub process_scope: ProcessScope, + focused: bool, + pub show_help: bool, + pub snapshot: Option, + pub last_error: Option, + pub process_scan_started_at: Option, + search: Option, + search_draft: Option, + process_search: SearchSummary, + tmpfs_search: SearchSummary, + shared_search: SearchSummary, + collapsed_processes: BTreeSet, + expanded_processes: BTreeSet, + collapsed_tmpfs: BTreeSet, + expanded_tmpfs: BTreeSet, + process_mapping_cache: BTreeMap, + process_mapping_started_at: Option<(Pid, Instant)>, + shared_scan_started_at: Option, + pub deletions: Vec, + confirmed_deletions: BTreeSet, + pub kill_confirmation: Option, + inventory_warnings: Vec, + tmpfs_refresh_warnings: Vec, + process_warnings: Vec, + pending_process_scan: Option, + tmpfs_selection: SelectionCustody, + process_rows: PaneRows, + tmpfs_rows: PaneRows, + shared_rows: PaneRows, + page_rows: PageRows, + key_sequence: KeySequence, +} + +pub struct KillConfirmation { + pub target: ProcessKillTarget, + opened_at: Instant, +} + +impl KillConfirmation { + #[must_use] + fn new(target: ProcessKillTarget) -> Self { + Self { + target, + opened_at: Instant::now(), + } + } + + #[must_use] + pub fn armed(&self) -> bool { + self.opened_at.elapsed() >= KILL_ARMING_DELAY + } + + #[must_use] + pub fn lock_remaining(&self) -> Duration { + KILL_ARMING_DELAY.saturating_sub(self.opened_at.elapsed()) + } +} + +pub struct ProcessKillTarget { + pub pid: Pid, + pub name: String, + pub command: String, + pidfd: OwnedFd, +} + +impl ProcessKillTarget { + fn capture(process: &ProcessNode) -> std::result::Result { + Ok(Self { + pid: process.pid, + name: process.name.clone(), + command: process.command.clone(), + pidfd: open_pidfd(process.pid)?, + }) + } + + #[must_use] + pub fn cli(&self) -> &str { + if self.command.is_empty() { + &self.name + } else { + &self.command + } + } + + fn send_sigterm(self) -> std::result::Result<(), String> { + pidfd_send_signal(self.pidfd, Signal::TERM) + .map_err(|error| format!("pidfd SIGTERM {}: {error}", self.pid)) + } +} + +pub struct ProcessMappingLedger { + pub pid: Pid, + pub elapsed: Duration, + pub cost: probe::ProcessMappingCost, + pub objects: Vec, + pub mappings_state: LedgerState, +} + +impl ProcessMappingLedger { + fn install(scan: probe::ProcessMappingScan) -> Self { + Self { + pid: scan.pid, + elapsed: scan.elapsed, + cost: scan.cost, + objects: scan.objects, + mappings_state: scan.mappings_state, + } + } +} + +pub struct DeleteTask { + pub path: PathBuf, + mount_point: PathBuf, + result: Receiver, +} + +enum DeleteOutcome { + Deleted, + Failed(String), +} + +#[derive(Clone, Debug)] +enum TombstoneCoverage { + Full, + Mount(PathBuf), +} + +impl TombstoneCoverage { + #[must_use] + fn covers(&self, path: &Path) -> bool { + match self { + Self::Full => true, + Self::Mount(mount_point) => path.starts_with(mount_point), + } + } +} + +impl App { + #[must_use] + pub fn new() -> Self { + Self { + tab: Tab::Processes, + metric: Metric::Pss, + process_scope: ProcessScope::SelfOnly, + focused: true, + show_help: false, + snapshot: None, + last_error: None, + process_scan_started_at: None, + search: None, + search_draft: None, + process_search: SearchSummary::new(Metric::Pss.label()), + tmpfs_search: SearchSummary::new("allocated"), + shared_search: SearchSummary::new(Metric::Pss.label()), + collapsed_processes: BTreeSet::new(), + expanded_processes: BTreeSet::new(), + collapsed_tmpfs: BTreeSet::new(), + expanded_tmpfs: BTreeSet::new(), + process_mapping_cache: BTreeMap::new(), + process_mapping_started_at: None, + shared_scan_started_at: None, + deletions: Vec::new(), + confirmed_deletions: BTreeSet::new(), + kill_confirmation: None, + inventory_warnings: Vec::new(), + tmpfs_refresh_warnings: Vec::new(), + process_warnings: Vec::new(), + pending_process_scan: None, + tmpfs_selection: SelectionCustody::default(), + process_rows: PaneRows::default(), + tmpfs_rows: PaneRows::default(), + shared_rows: PaneRows::default(), + page_rows: PageRows::default(), + key_sequence: KeySequence::default(), + } + } + + pub fn set_terminal_height(&mut self, height: u16) { + self.page_rows = PageRows::from_terminal_height(height); + } + + pub fn start_visible_work(&mut self, commands: &Sender) { + self.request_current_pane(commands); + self.sync_process_scanning(commands); + } + + #[must_use] + pub fn is_focused(&self) -> bool { + self.focused + } + + pub fn set_focused(&mut self, focused: bool, commands: &Sender) { + if self.focused == focused { + return; + } + self.focused = focused; + self.sync_process_scanning(commands); + } + + pub fn apply_worker_event(&mut self, event: WorkerEvent, commands: &Sender) { + match event { + WorkerEvent::InventoryReady(result) => self.apply_inventory_result(result), + WorkerEvent::TmpfsMountReady(result) => self.apply_tmpfs_mount_result(result), + WorkerEvent::ProcessesStarted(started) => self.process_scan_started_at = Some(started), + WorkerEvent::ProcessesReady(result) => self.apply_process_result(result, commands), + WorkerEvent::ProcessMappingsReady(result) => self.apply_process_mappings_result(result), + WorkerEvent::SharedObjectsStarted(started) => { + self.shared_scan_started_at = Some(started); + } + WorkerEvent::SharedObjectsReady(result) => self.apply_shared_objects_result(result), + } + } + + fn apply_inventory_result(&mut self, result: Result>) { + match result { + Ok(snapshot) => { + self.last_error = None; + self.install_inventory_snapshot(*snapshot); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn apply_tmpfs_mount_result(&mut self, result: Result>) { + match result { + Ok(scan) => { + self.last_error = None; + self.install_tmpfs_mount_scan(*scan); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn apply_process_result( + &mut self, + result: Result>, + commands: &Sender, + ) { + self.process_scan_started_at = None; + match result { + Ok(scan) => { + self.last_error = None; + self.install_process_scan(*scan); + self.request_selected_process_mappings(commands); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn apply_process_mappings_result(&mut self, result: Result>) { + match result { + Ok(mut scan) => { + if self + .process_mapping_started_at + .is_some_and(|(pid, _)| pid == scan.pid) + { + self.process_mapping_started_at = None; + } + self.process_warnings.append(&mut scan.warnings); + let ledger = ProcessMappingLedger::install(*scan); + let _ = self.process_mapping_cache.insert(ledger.pid, ledger); + self.last_error = None; + self.rebuild_snapshot_warnings(); + } + Err(error) => { + self.process_mapping_started_at = None; + self.last_error = Some(format!("{error:#}")); + } + } + } + + fn apply_shared_objects_result(&mut self, result: Result>) { + self.shared_scan_started_at = None; + match result { + Ok(mut scan) => { + self.last_error = None; + self.process_warnings.append(&mut scan.warnings); + self.install_shared_objects_scan(*scan); + } + Err(error) => self.last_error = Some(format!("{error:#}")), + } + } + + fn install_inventory_snapshot(&mut self, mut snapshot: Snapshot) { + self.inventory_warnings = std::mem::take(&mut snapshot.warnings); + self.tmpfs_refresh_warnings.clear(); + let tmpfs_fresh = !snapshot.tmpfs_mounts.is_empty(); + if let Some(current) = self.snapshot.take() { + snapshot.process_tree = current.process_tree; + snapshot.shared_objects = current.shared_objects; + if snapshot.tmpfs_mounts.is_empty() { + snapshot.tmpfs_mounts = current.tmpfs_mounts; + } + } + if tmpfs_fresh { + self.reconcile_confirmed_deletions(&snapshot.tmpfs_mounts, TombstoneCoverage::Full); + } + self.prune_tmpfs_tombstones(&mut snapshot.tmpfs_mounts); + probe::rebuild_snapshot_derived(&mut snapshot); + self.snapshot = Some(snapshot); + self.rebuild_snapshot_warnings(); + if tmpfs_fresh { + self.rebuild_tmpfs_rows(); + } + + if let Some(scan) = self.pending_process_scan.take() { + self.install_process_scan(scan); + } + self.rebuild_shared_rows(); + } + + fn install_process_scan(&mut self, mut scan: probe::ProcessScan) { + self.process_warnings = std::mem::take(&mut scan.warnings); + if self.snapshot.is_none() { + self.snapshot = Some(empty_snapshot(scan.meminfo.clone())); + } + let Some(snapshot) = self.snapshot.as_mut() else { + self.pending_process_scan = Some(scan); + return; + }; + scan.install(snapshot); + self.rebuild_snapshot_warnings(); + self.rebuild_process_rows(); + self.retain_live_process_mapping_cache(); + } + + fn install_shared_objects_scan(&mut self, scan: probe::SharedObjectsScan) { + if self.snapshot.is_none() { + self.snapshot = Some(empty_snapshot(scan.meminfo.clone())); + } + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + snapshot.captured_at = scan.captured_at; + snapshot.elapsed = scan.elapsed; + snapshot.meminfo = scan.meminfo; + snapshot.shared_objects = scan.shared_objects; + probe::rebuild_snapshot_derived(snapshot); + self.rebuild_snapshot_warnings(); + self.rebuild_shared_rows(); + } + + fn install_tmpfs_mount_scan(&mut self, mut scan: probe::TmpfsMountScan) { + self.tmpfs_refresh_warnings = std::mem::take(&mut scan.warnings); + self.reconcile_confirmed_deletions( + std::slice::from_ref(&scan.mount), + TombstoneCoverage::Mount(scan.mount.mount_point.clone()), + ); + self.prune_tmpfs_tombstones(std::slice::from_mut(&mut scan.mount)); + if self.snapshot.is_none() { + self.snapshot = Some(empty_snapshot(Meminfo::default())); + } + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + + snapshot.captured_at = scan.captured_at; + snapshot.elapsed = scan.elapsed; + if let Some(slot) = snapshot + .tmpfs_mounts + .iter_mut() + .find(|mount| mount.mount_point == scan.mount.mount_point) + { + *slot = scan.mount; + } else { + snapshot.tmpfs_mounts.push(scan.mount); + } + snapshot + .tmpfs_mounts + .sort_by_key(|mount| std::cmp::Reverse(mount.root.allocated)); + probe::rebuild_snapshot_derived(snapshot); + self.rebuild_snapshot_warnings(); + self.rebuild_tmpfs_rows(); + } + + fn rebuild_snapshot_warnings(&mut self) { + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + let mut warnings = Vec::with_capacity( + self.inventory_warnings.len() + + self.tmpfs_refresh_warnings.len() + + self.process_warnings.len(), + ); + warnings.extend(self.inventory_warnings.iter().cloned()); + warnings.extend(self.tmpfs_refresh_warnings.iter().cloned()); + warnings.extend(self.process_warnings.iter().cloned()); + snapshot.warnings = warnings; + } + + fn tmpfs_tombstones(&self) -> BTreeSet { + let mut tombstones = self.confirmed_deletions.clone(); + tombstones.extend(self.deletions.iter().map(|task| task.path.clone())); + tombstones + } + + fn prune_tmpfs_tombstones(&self, mounts: &mut [TmpfsMount]) { + let tombstones = self.tmpfs_tombstones(); + prune_tmpfs_tombstones(mounts, &tombstones); + } + + fn prune_tmpfs_path(&mut self, path: &Path) { + let mut tombstones = self.tmpfs_tombstones(); + let _ = tombstones.insert(path.to_path_buf()); + let Some(snapshot) = self.snapshot.as_mut() else { + return; + }; + prune_tmpfs_tombstones(&mut snapshot.tmpfs_mounts, &tombstones); + probe::rebuild_snapshot_derived(snapshot); + self.rebuild_tmpfs_rows(); + } + + fn reconcile_confirmed_deletions( + &mut self, + mounts: &[TmpfsMount], + coverage: TombstoneCoverage, + ) { + self.confirmed_deletions.retain(|path| { + if !coverage.covers(path) { + return true; + } + mounts + .iter() + .filter(|mount| path.starts_with(&mount.mount_point)) + .any(|mount| tmpfs_tree_contains_path(&mount.root, path)) + }); + } + + pub fn poll_deletion(&mut self, commands: &Sender) -> bool { + let mut changed = false; + let mut index = 0; + while index < self.deletions.len() { + match self.deletions[index].result.try_recv() { + Ok(DeleteOutcome::Deleted) => { + let task = self.deletions.remove(index); + let _ = self.confirmed_deletions.insert(task.path); + self.last_error = None; + let _ = commands.send(WorkerCommand::RefreshTmpfsMount(task.mount_point)); + changed = true; + } + Ok(DeleteOutcome::Failed(error)) => { + let task = self.deletions.remove(index); + let _ = self.confirmed_deletions.remove(&task.path); + self.last_error = Some(error); + let _ = commands.send(WorkerCommand::RefreshTmpfsMount(task.mount_point)); + changed = true; + } + Err(TryRecvError::Empty) => index += 1, + Err(TryRecvError::Disconnected) => { + let task = self.deletions.remove(index); + let _ = self.confirmed_deletions.remove(&task.path); + self.last_error = Some(format!( + "delete task disconnected for {}", + task.path.display() + )); + let _ = commands.send(WorkerCommand::RefreshTmpfsMount(task.mount_point)); + changed = true; + } + } + } + changed + } + + #[must_use] + pub fn needs_periodic_redraw(&self) -> bool { + self.focused + && (self.kill_confirmation.is_some() + || self.process_scan_started_at.is_some() + || self.process_mapping_started_at.is_some() + || self.shared_scan_started_at.is_some()) + } + + fn rebuild_filterable_rows(&mut self) { + self.rebuild_process_rows(); + self.rebuild_tmpfs_rows(); + self.rebuild_shared_rows(); + } + + #[must_use] + pub fn tab_labels() -> Vec { + Tab::ALL + .iter() + .enumerate() + .map(|(index, tab)| format!("{} {}", index + 1, tab.title())) + .collect() + } + + #[must_use] + pub fn current_time_label(&self) -> String { + let Some(snapshot) = self.snapshot.as_ref() else { + return "loading".to_string(); + }; + match snapshot.captured_at.duration_since(SystemTime::UNIX_EPOCH) { + Ok(since_epoch) => format!("captured {}", since_epoch.as_secs()), + Err(_) => "captured".to_string(), + } + } + + #[must_use] + pub fn hotkey_sections(&self) -> HotkeySections { + HotkeySections { + global: crate::nav::global_hotkeys(), + pane_title: self.tab.title(), + pane: self.tab.hotkeys(), + } + } + + fn rebuild_process_rows(&mut self) { + let (rows, summary) = self.snapshot.as_ref().map_or_else( + || (Vec::new(), SearchSummary::new(self.metric.label())), + |snapshot| { + build_process_rows( + snapshot, + self.metric, + self.process_scope, + &self.collapsed_processes, + &self.expanded_processes, + self.search.as_ref(), + ) + }, + ); + self.process_search = summary; + self.process_rows.install(rows); + } + + fn rebuild_tmpfs_rows(&mut self) { + let (rows, summary) = self.snapshot.as_ref().map_or_else( + || (Vec::new(), SearchSummary::new("allocated")), + |snapshot| { + build_tmpfs_rows( + snapshot, + self.process_scope, + &self.collapsed_tmpfs, + &self.expanded_tmpfs, + self.search.as_ref(), + ) + }, + ); + self.tmpfs_search = summary; + if self.tmpfs_selection.preserves_anchor() { + self.tmpfs_rows.install(rows); + } else { + self.tmpfs_rows.install_pinned_to_top(rows); + } + } + + fn rebuild_shared_rows(&mut self) { + let (rows, summary) = self.snapshot.as_ref().map_or_else( + || (Vec::new(), SearchSummary::new(self.metric.label())), + |snapshot| build_shared_rows(snapshot, self.metric, self.search.as_ref()), + ); + self.shared_search = summary; + self.shared_rows.install(rows); + } + + #[must_use] + pub fn search_pattern(&self) -> Option<&str> { + self.search.as_ref().map(Search::pattern) + } + + #[must_use] + pub fn search_draft(&self) -> Option<&SearchDraft> { + self.search_draft.as_ref() + } + + #[must_use] + pub fn search_summary(&self) -> Option<&SearchSummary> { + let _active = self.search.as_ref()?; + Some(match self.tab { + Tab::Overview => return None, + Tab::Processes => &self.process_search, + Tab::Tmpfs => &self.tmpfs_search, + Tab::Shared => &self.shared_search, + }) + } + + #[must_use] + pub fn search_scope_label(&self) -> &'static str { + self.process_scope.label() + } + + #[must_use] + pub fn deletion_count(&self) -> usize { + self.deletions.len() + } + + #[must_use] + pub fn process_rows(&self) -> &[FlatProcessRow] { + self.process_rows.rows() + } + + #[must_use] + pub fn tmpfs_rows(&self) -> &[FlatTmpfsRow] { + self.tmpfs_rows.rows() + } + + #[must_use] + pub fn shared_rows(&self) -> &[FlatSharedRow] { + self.shared_rows.rows() + } + + #[must_use] + pub fn selected_process_row(&self) -> usize { + self.process_rows.selected_index() + } + + #[must_use] + pub fn selected_tmpfs_row(&self) -> usize { + self.tmpfs_rows.selected_index() + } + + #[must_use] + pub fn selected_shared_row(&self) -> usize { + self.shared_rows.selected_index() + } + + #[must_use] + pub fn selected_tmpfs_entry(&self) -> Option<&FlatTmpfsRow> { + self.tmpfs_rows.selected() + } + + #[must_use] + pub fn selected_process(&self) -> Option<&ProcessNode> { + let row = self.process_rows.selected()?; + let snapshot = self.snapshot.as_ref()?; + snapshot.process_tree.nodes.get(row.index) + } + + #[must_use] + pub fn selected_process_objects(&self) -> &[ObjectUsage] { + let Some(process) = self.selected_process() else { + return &[]; + }; + self.process_mapping_cache + .get(&process.pid) + .map_or(&[], |ledger| ledger.objects.as_slice()) + } + + #[must_use] + pub fn selected_process_mapping_status(&self) -> &'static str { + let Some(process) = self.selected_process() else { + return "none"; + }; + if self + .process_mapping_started_at + .is_some_and(|(pid, _)| pid == process.pid) + { + return "loading"; + } + self.process_mapping_cache + .get(&process.pid) + .map_or(process.mappings_state.label(), |ledger| { + ledger.mappings_state.label() + }) + } + + #[must_use] + pub fn selected_process_mapping_loading(&self) -> Option<(Pid, Duration)> { + let process = self.selected_process()?; + let (pid, started_at) = self.process_mapping_started_at?; + (pid == process.pid).then_some((pid, started_at.elapsed())) + } + + #[must_use] + pub fn selected_process_mapping_scan_label(&self) -> String { + if let Some((pid, elapsed)) = self.selected_process_mapping_loading() { + return format!("loading pid {pid}: {} ms", elapsed.as_millis()); + } + let Some(process) = self.selected_process() else { + return "none".to_string(); + }; + self.process_mapping_cache.get(&process.pid).map_or_else( + || "not loaded".to_string(), + |ledger| { + format!( + "{} ms (mount {} read {} parse {})", + ledger.elapsed.as_millis(), + ledger.cost.mount_index.as_millis(), + ledger.cost.read.as_millis(), + ledger.cost.parse.as_millis() + ) + }, + ) + } + + #[must_use] + pub fn process_mapping_started_at(&self) -> Option<(Pid, Instant)> { + self.process_mapping_started_at + } + + #[must_use] + pub fn shared_scan_started_at(&self) -> Option { + self.shared_scan_started_at + } + + #[must_use] + pub fn selected_tmpfs_mount(&self) -> Option<&TmpfsMount> { + let snapshot = self.snapshot.as_ref()?; + let row = self.tmpfs_rows.selected()?; + snapshot.tmpfs_mounts.get(row.mount_index) + } + + #[must_use] + pub fn selected_shared_object(&self) -> Option<&SharedObject> { + let row = self.shared_rows.selected()?; + self.snapshot.as_ref()?.shared_objects.get(row.index) + } + + pub fn handle_key(&mut self, key: KeyEvent, commands: &Sender) -> bool { + if self.kill_confirmation.is_some() { + return self.handle_kill_confirmation_key(key, commands); + } + if self.search_draft.is_some() { + return self.handle_search_key(key); + } + if self.show_help { + return self.handle_help_key(key); + } + if matches!(key.code, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL)) { + return true; + } + + match self.key_sequence.resolve(key) { + SequenceResolution::Unmatched(key) => self.handle_single_key(key, commands), + SequenceResolution::Pending | SequenceResolution::Cancelled => false, + SequenceResolution::Command(command) => { + self.handle_sequence_command(command, commands); + false + } + } + } + + fn handle_single_key(&mut self, key: KeyEvent, commands: &Sender) -> bool { + match key.code { + KeyCode::Char('q') => return true, + KeyCode::Esc => {} + KeyCode::Char('?') => self.show_help = !self.show_help, + KeyCode::Char('/') => self.open_search(), + KeyCode::Char('f') => self.clear_search(), + KeyCode::Tab => self.select_tab(self.tab.next(), commands), + KeyCode::BackTab => self.select_tab(self.tab.previous(), commands), + KeyCode::Char('1') => self.select_tab(Tab::Overview, commands), + KeyCode::Char('2') => self.select_tab(Tab::Processes, commands), + KeyCode::Char('3') => self.select_tab(Tab::Tmpfs, commands), + KeyCode::Char('4') => self.select_tab(Tab::Shared, commands), + KeyCode::Char('s') => { + self.metric = self.metric.next(); + self.rebuild_process_rows(); + self.rebuild_shared_rows(); + } + KeyCode::Char('m') => { + self.process_scope = self.process_scope.next(); + self.rebuild_filterable_rows(); + } + KeyCode::Char('d') => self.delete_current_tmpfs_entry(), + KeyCode::Char('K') => self.arm_process_kill(), + KeyCode::Char('r') => self.refresh_current_pane(commands), + KeyCode::Down | KeyCode::Char('j') => { + let _ = self.move_selection_and_request_mappings(1, commands); + } + KeyCode::Up | KeyCode::Char('k') => { + let _ = self.move_selection_and_request_mappings(-1, commands); + } + KeyCode::PageDown => { + let _ = self.page_selection_and_request_mappings(PageDirection::Down, commands); + } + KeyCode::PageUp => { + let _ = self.page_selection_and_request_mappings(PageDirection::Up, commands); + } + KeyCode::Left | KeyCode::Char('h') => self.collapse_current(), + KeyCode::Right | KeyCode::Char('l') => self.expand_current(), + KeyCode::Enter => self.toggle_current(), + _ => {} + } + false + } + + fn handle_sequence_command( + &mut self, + command: SequenceCommand, + commands: &Sender, + ) { + match command { + SequenceCommand::FirstRow => { + let _ = self.select_edge_and_request_mappings(RowEdge::First, commands); + } + SequenceCommand::LastRow => { + let _ = self.select_edge_and_request_mappings(RowEdge::Last, commands); + } + } + } + + pub fn handle_mouse(&mut self, mouse: MouseEvent, commands: &Sender) -> bool { + if self.kill_confirmation.is_some() || self.show_help { + return false; + } + self.key_sequence = KeySequence::Root; + + match mouse.kind { + MouseEventKind::ScrollDown => self.move_selection_and_request_mappings(1, commands), + MouseEventKind::ScrollUp => self.move_selection_and_request_mappings(-1, commands), + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => false, + MouseEventKind::Down(_) + | MouseEventKind::Up(_) + | MouseEventKind::Drag(_) + | MouseEventKind::Moved => false, + } + } + + fn select_tab(&mut self, tab: Tab, commands: &Sender) { + self.tab = tab; + if tab == Tab::Tmpfs { + self.seize_tmpfs_selection(); + } + self.request_current_pane(commands); + self.sync_process_scanning(commands); + } + + fn sync_process_scanning(&self, commands: &Sender) { + let _ = commands.send(WorkerCommand::SetProcessScanning( + self.focused && self.tab.drives_process_scans(), + )); + } + + fn request_current_pane(&mut self, commands: &Sender) { + match self.tab { + Tab::Overview => { + let _ = commands.send(WorkerCommand::RefreshInventory); + } + Tab::Processes => self.request_selected_process_mappings(commands), + Tab::Tmpfs => { + let _ = commands.send(WorkerCommand::RefreshTmpfsMounts); + } + Tab::Shared => { + let _ = commands.send(WorkerCommand::RefreshSharedObjects); + } + } + } + + fn request_selected_process_mappings(&mut self, commands: &Sender) { + if self.tab != Tab::Processes { + return; + } + let Some(process) = self.selected_process() else { + return; + }; + if self.process_mapping_cache.contains_key(&process.pid) + || self + .process_mapping_started_at + .is_some_and(|(pid, _)| pid == process.pid) + { + return; + } + let pid = process.pid; + self.process_mapping_started_at = Some((pid, Instant::now())); + let _ = commands.send(WorkerCommand::RefreshProcessMappings(pid)); + } + + fn retain_live_process_mapping_cache(&mut self) { + let Some(snapshot) = self.snapshot.as_ref() else { + self.process_mapping_cache.clear(); + self.process_mapping_started_at = None; + return; + }; + let live = snapshot + .process_tree + .nodes + .iter() + .map(|node| node.pid) + .collect::>(); + self.process_mapping_cache + .retain(|pid, _ledger| live.contains(pid)); + if self + .process_mapping_started_at + .is_some_and(|(pid, _)| !live.contains(&pid)) + { + self.process_mapping_started_at = None; + } + } + + fn refresh_current_pane(&mut self, commands: &Sender) { + let command = match self.tab { + Tab::Overview => WorkerCommand::RefreshInventory, + Tab::Processes => WorkerCommand::RefreshProcesses, + Tab::Tmpfs => self + .selected_tmpfs_mount() + .map(|mount| WorkerCommand::RefreshTmpfsMount(mount.mount_point.clone())) + .unwrap_or(WorkerCommand::RefreshTmpfsMounts), + Tab::Shared => WorkerCommand::RefreshSharedObjects, + }; + let _ = commands.send(command); + } + + fn open_search(&mut self) { + self.key_sequence = KeySequence::Root; + self.search_draft = Some(SearchDraft::new(self.search.as_ref())); + } + + fn clear_search(&mut self) { + if self.search.take().is_some() { + self.rebuild_filterable_rows(); + } + } + + fn handle_search_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => true, + KeyCode::Esc => { + self.search_draft = None; + false + } + KeyCode::Enter => { + self.commit_search(); + false + } + KeyCode::Backspace => { + if let Some(draft) = self.search_draft.as_mut() { + draft.backspace(); + } + false + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(draft) = self.search_draft.as_mut() { + draft.clear(); + } + false + } + KeyCode::Char(character) if plain_char(key).is_some() => { + if let Some(draft) = self.search_draft.as_mut() { + draft.push(character); + } + false + } + _ => false, + } + } + + fn commit_search(&mut self) { + let Some(draft) = self.search_draft.take() else { + return; + }; + let input = draft.into_input(); + match Search::compile(input.clone()) { + Ok(search) => { + self.search = search; + self.rebuild_filterable_rows(); + } + Err(error) => { + let mut draft = SearchDraft::from_input(input); + draft.fail(error); + self.search_draft = Some(draft); + } + } + } + + fn handle_help_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => true, + KeyCode::Esc | KeyCode::Char('?') => { + self.show_help = false; + false + } + _ => false, + } + } + + fn handle_kill_confirmation_key( + &mut self, + key: KeyEvent, + commands: &Sender, + ) -> bool { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => true, + KeyCode::Esc | KeyCode::Char('n') => { + self.kill_confirmation = None; + false + } + KeyCode::Char('y') + if self + .kill_confirmation + .as_ref() + .is_some_and(KillConfirmation::armed) => + { + self.confirm_process_kill(commands); + false + } + _ => false, + } + } + + fn move_selection(&mut self, delta: isize) -> bool { + match self.tab { + Tab::Overview => false, + Tab::Processes => self.process_rows.move_by(delta), + Tab::Tmpfs => self.tmpfs_rows.move_by(delta), + Tab::Shared => self.shared_rows.move_by(delta), + } + } + + fn seize_tmpfs_selection(&mut self) { + if self.tmpfs_selection.preserves_anchor() { + return; + } + let _ = self.tmpfs_rows.select_edge(RowEdge::First); + self.tmpfs_selection.seize(); + } + + fn page_selection(&mut self, direction: PageDirection) -> bool { + self.move_selection(self.page_rows.delta(direction)) + } + + fn move_selection_and_request_mappings( + &mut self, + delta: isize, + commands: &Sender, + ) -> bool { + let changed = self.move_selection(delta); + self.request_selected_process_mappings_after(changed, commands) + } + + fn page_selection_and_request_mappings( + &mut self, + direction: PageDirection, + commands: &Sender, + ) -> bool { + let changed = self.page_selection(direction); + self.request_selected_process_mappings_after(changed, commands) + } + + fn select_edge_and_request_mappings( + &mut self, + edge: RowEdge, + commands: &Sender, + ) -> bool { + let changed = self.select_edge(edge); + self.request_selected_process_mappings_after(changed, commands) + } + + fn request_selected_process_mappings_after( + &mut self, + changed: bool, + commands: &Sender, + ) -> bool { + if changed { + self.request_selected_process_mappings(commands); + } + changed + } + + fn select_edge(&mut self, edge: RowEdge) -> bool { + match self.tab { + Tab::Overview => false, + Tab::Processes => self.process_rows.select_edge(edge), + Tab::Tmpfs => self.tmpfs_rows.select_edge(edge), + Tab::Shared => self.shared_rows.select_edge(edge), + } + } + + fn collapse_current(&mut self) { + match self.tab { + Tab::Processes => { + let Some((pid, fold)) = self.process_rows.selected().map(|row| (row.pid, row.fold)) + else { + return; + }; + if fold != RowFold::Leaf { + let _ = self.expanded_processes.remove(&pid); + let _ = self.collapsed_processes.insert(pid); + self.rebuild_process_rows(); + } + } + Tab::Tmpfs => { + let Some((path, fold)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.fold)) + else { + return; + }; + if fold != RowFold::Leaf { + let _ = self.expanded_tmpfs.remove(&path); + let _ = self.collapsed_tmpfs.insert(path); + self.rebuild_tmpfs_rows(); + } + } + Tab::Overview | Tab::Shared => {} + } + } + + fn expand_current(&mut self) { + match self.tab { + Tab::Processes => { + if let Some((pid, fold)) = + self.process_rows.selected().map(|row| (row.pid, row.fold)) + && fold != RowFold::Leaf + { + let _ = self.collapsed_processes.remove(&pid); + let _ = self.expanded_processes.insert(pid); + self.rebuild_process_rows(); + } + } + Tab::Tmpfs => { + if let Some((path, fold)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.fold)) + && fold != RowFold::Leaf + { + let _ = self.collapsed_tmpfs.remove(&path); + let _ = self.expanded_tmpfs.insert(path); + self.rebuild_tmpfs_rows(); + } + } + Tab::Overview | Tab::Shared => {} + } + } + + fn toggle_current(&mut self) { + match self.tab { + Tab::Processes => { + let Some((pid, fold)) = self.process_rows.selected().map(|row| (row.pid, row.fold)) + else { + return; + }; + match fold { + RowFold::Leaf => return, + RowFold::Collapsed => { + let _ = self.collapsed_processes.remove(&pid); + let _ = self.expanded_processes.insert(pid); + } + RowFold::Expanded => { + let _ = self.expanded_processes.remove(&pid); + let _ = self.collapsed_processes.insert(pid); + } + } + self.rebuild_process_rows(); + } + Tab::Tmpfs => { + let Some((path, fold)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.fold)) + else { + return; + }; + match fold { + RowFold::Leaf => return, + RowFold::Collapsed => { + let _ = self.collapsed_tmpfs.remove(&path); + let _ = self.expanded_tmpfs.insert(path); + } + RowFold::Expanded => { + let _ = self.expanded_tmpfs.remove(&path); + let _ = self.collapsed_tmpfs.insert(path); + } + } + self.rebuild_tmpfs_rows(); + } + Tab::Overview | Tab::Shared => {} + } + } + + fn delete_current_tmpfs_entry(&mut self) { + if self.tab != Tab::Tmpfs { + return; + } + let Some((path, kind)) = self + .tmpfs_rows + .selected() + .map(|row| (row.path.clone(), row.kind)) + else { + return; + }; + let Some(mount_point) = self + .selected_tmpfs_mount() + .map(|mount| mount.mount_point.clone()) + else { + return; + }; + + if kind == TmpfsNodeKind::Mount { + self.last_error = Some(format!( + "refusing to delete tmpfs mount root {}", + path.display() + )); + return; + } + + let Some(successor) = self.tmpfs_rows.selected_slot() else { + return; + }; + let (sender, result) = mpsc::channel(); + let task_path = path.clone(); + let _handle = thread::spawn(move || { + let outcome = delete_tmpfs_entry(&task_path, kind) + .map_err(|error| format!("delete {}: {error}", task_path.display())) + .map_or_else(DeleteOutcome::Failed, |()| DeleteOutcome::Deleted); + let _ = sender.send(outcome); + }); + + self.last_error = None; + let _ = self.collapsed_tmpfs.remove(&path); + let _ = self.expanded_tmpfs.remove(&path); + self.deletions.push(DeleteTask { + path: path.clone(), + mount_point, + result, + }); + self.prune_tmpfs_path(&path); + let _ = self.tmpfs_rows.select_clamped(successor); + } + + fn arm_process_kill(&mut self) { + if self.tab != Tab::Processes { + return; + } + let Some(process) = self.selected_process() else { + return; + }; + + match ProcessKillTarget::capture(process) { + Ok(target) => { + self.last_error = None; + self.kill_confirmation = Some(KillConfirmation::new(target)); + } + Err(error) => self.last_error = Some(error), + } + } + + fn confirm_process_kill(&mut self, commands: &Sender) { + let Some(confirmation) = self.kill_confirmation.take() else { + return; + }; + match confirmation.target.send_sigterm() { + Ok(()) => { + self.last_error = None; + let _ = commands.send(WorkerCommand::RefreshProcesses); + } + Err(error) => self.last_error = Some(error), + } + } +} + +fn open_pidfd(pid: Pid) -> std::result::Result { + let Some(kernel_pid) = KernelPid::from_raw(pid.0) else { + return Err(format!("cannot arm SIGTERM for invalid pid {pid}")); + }; + pidfd_open(kernel_pid, PidfdFlags::empty()) + .map_err(|error| format!("pidfd_open {pid}: {error}")) +} + +fn delete_tmpfs_entry(path: &Path, kind: TmpfsNodeKind) -> std::io::Result<()> { + let result = match kind { + TmpfsNodeKind::Directory => fs::remove_dir_all(path), + TmpfsNodeKind::Mount => Ok(()), + TmpfsNodeKind::File + | TmpfsNodeKind::Symlink + | TmpfsNodeKind::Socket + | TmpfsNodeKind::Fifo + | TmpfsNodeKind::CharDevice + | TmpfsNodeKind::BlockDevice + | TmpfsNodeKind::Other => fs::remove_file(path), + }; + match result { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct TmpfsUsage { + allocated: Bytes, + logical: Bytes, +} + +impl TmpfsUsage { + #[must_use] + fn from_node(node: &TmpfsNode) -> Self { + Self { + allocated: node.allocated, + logical: node.logical, + } + } + + fn absorb(&mut self, usage: Self) { + self.allocated += usage.allocated; + self.logical += usage.logical; + } +} + +fn prune_tmpfs_tombstones(mounts: &mut [TmpfsMount], tombstones: &BTreeSet) { + if tombstones.is_empty() { + return; + } + for mount in mounts { + let removed = prune_tmpfs_node_children(&mut mount.root, tombstones); + mount.root.allocated -= removed.allocated; + mount.root.logical -= removed.logical; + } +} + +fn prune_tmpfs_node_children(node: &mut TmpfsNode, tombstones: &BTreeSet) -> TmpfsUsage { + let mut removed = TmpfsUsage::default(); + node.children.retain_mut(|child| { + if tmpfs_path_is_tombstoned(&child.path, tombstones) { + removed.absorb(TmpfsUsage::from_node(child)); + return false; + } + let child_removed = prune_tmpfs_node_children(child, tombstones); + child.allocated -= child_removed.allocated; + child.logical -= child_removed.logical; + removed.absorb(child_removed); + true + }); + removed +} + +fn tmpfs_path_is_tombstoned(path: &Path, tombstones: &BTreeSet) -> bool { + tombstones + .iter() + .any(|tombstone| path == tombstone || path.starts_with(tombstone)) +} + +fn tmpfs_tree_contains_path(node: &TmpfsNode, path: &Path) -> bool { + node.path == path + || (path.starts_with(&node.path) + && node + .children + .iter() + .any(|child| tmpfs_tree_contains_path(child, path))) +} + +fn empty_snapshot(meminfo: Meminfo) -> Snapshot { + let mut snapshot = Snapshot { + captured_at: SystemTime::now(), + elapsed: Duration::ZERO, + meminfo, + overview: crate::model::Overview::default(), + process_tree: crate::model::ProcessTree::default(), + shared_objects: Vec::new(), + sysv_segments: Vec::new(), + tmpfs_mounts: Vec::new(), + warnings: Vec::new(), + }; + probe::rebuild_snapshot_derived(&mut snapshot); + snapshot +} + +#[cfg(test)] +mod tests; diff --git a/crates/memview/src/app/rows.rs b/crates/memview/src/app/rows.rs new file mode 100644 index 0000000..d6d904d --- /dev/null +++ b/crates/memview/src/app/rows.rs @@ -0,0 +1,484 @@ +use super::*; + +pub(super) fn build_process_rows( + snapshot: &Snapshot, + metric: Metric, + scope: ProcessScope, + collapsed: &BTreeSet, + expanded: &BTreeSet, + search: Option<&Search>, +) -> (Vec, SearchSummary) { + let mut summary = SearchSummary::new(metric.label()); + let mut rows = Vec::new(); + let roots = sorted_process_roots(snapshot, metric, scope); + if let Some(search) = search { + match scope { + ProcessScope::SelfOnly => { + for root in roots { + push_process_search_self( + root, + 0, + snapshot, + metric, + search, + &mut rows, + &mut summary, + ); + } + } + ProcessScope::SelfAndChildren => { + for root in roots { + let _ = push_process_search_tree( + root, + 0, + snapshot, + metric, + search, + false, + &mut rows, + &mut summary, + ); + } + } + } + return (rows, summary); + } + + let policy = FoldPolicy { + collapsed, + expanded, + de_minimis: DeMinimis::from_largest_non_root( + snapshot.meminfo.get("MemTotal"), + largest_process_non_root_subtree(snapshot, metric), + ), + }; + for root in roots { + push_process_rows(root, 0, snapshot, metric, scope, &policy, &mut rows); + } + (rows, summary) +} + +pub(super) fn build_tmpfs_rows( + snapshot: &Snapshot, + scope: ProcessScope, + collapsed: &BTreeSet, + expanded: &BTreeSet, + search: Option<&Search>, +) -> (Vec, SearchSummary) { + let mut summary = SearchSummary::new("allocated"); + let mut rows = Vec::new(); + if let Some(search) = search { + match scope { + ProcessScope::SelfOnly => { + for (mount_index, mount) in snapshot.tmpfs_mounts.iter().enumerate() { + push_tmpfs_search_self( + mount_index, + &mount.root, + 0, + search, + &mut rows, + &mut summary, + ); + } + } + ProcessScope::SelfAndChildren => { + for (mount_index, mount) in snapshot.tmpfs_mounts.iter().enumerate() { + let _ = push_tmpfs_search_tree( + mount_index, + &mount.root, + 0, + search, + false, + &mut rows, + &mut summary, + ); + } + } + } + return (rows, summary); + } + + let policy = FoldPolicy { + collapsed, + expanded, + de_minimis: DeMinimis::from_largest_non_root( + snapshot.meminfo.get("MemTotal"), + largest_tmpfs_non_root_subtree(snapshot), + ), + }; + for (mount_index, mount) in snapshot.tmpfs_mounts.iter().enumerate() { + push_tmpfs_rows(mount_index, &mount.root, 0, &policy, &mut rows); + } + (rows, summary) +} + +pub(super) fn build_shared_rows( + snapshot: &Snapshot, + metric: Metric, + search: Option<&Search>, +) -> (Vec, SearchSummary) { + let mut summary = SearchSummary::new(metric.label()); + let rows = snapshot + .shared_objects + .iter() + .enumerate() + .filter_map(|(index, object)| { + let direct = search.is_none_or(|search| shared_matches(search, object)); + if !direct { + return None; + } + if search.is_some() { + summary.strike(object.rollup.metric(metric)); + } + Some(FlatSharedRow { + index, + key: (object.kind, object.label.clone()), + search: search.map_or(SearchRole::Ordinary, |_| SearchRole::Match), + }) + }) + .collect(); + (rows, summary) +} + +fn sorted_process_roots(snapshot: &Snapshot, metric: Metric, scope: ProcessScope) -> Vec { + let mut roots = snapshot.process_tree.roots.clone(); + roots.sort_by(|lhs, rhs| { + process_cmp( + &snapshot.process_tree.nodes[*lhs], + &snapshot.process_tree.nodes[*rhs], + metric, + scope, + ) + }); + roots +} + +fn largest_process_non_root_subtree(snapshot: &Snapshot, metric: Metric) -> Bytes { + snapshot + .process_tree + .roots + .iter() + .flat_map(|root| snapshot.process_tree.nodes[*root].children.iter().copied()) + .map(|child| largest_process_subtree(child, snapshot, metric)) + .max() + .unwrap_or(Bytes::ZERO) +} + +fn largest_process_subtree(index: usize, snapshot: &Snapshot, metric: Metric) -> Bytes { + let node = &snapshot.process_tree.nodes[index]; + let child_max = node + .children + .iter() + .copied() + .map(|child| largest_process_subtree(child, snapshot, metric)) + .max() + .unwrap_or(Bytes::ZERO); + if node.children.is_empty() { + child_max + } else { + child_max.max(node.subtree.metric(metric)) + } +} + +fn push_process_rows( + index: usize, + depth: usize, + snapshot: &Snapshot, + metric: Metric, + scope: ProcessScope, + policy: &FoldPolicy<'_, Pid>, + rows: &mut Vec, +) { + let node = &snapshot.process_tree.nodes[index]; + let fold = policy.row_fold( + &node.pid, + depth, + !node.children.is_empty(), + node.subtree.metric(metric), + ); + rows.push(FlatProcessRow { + index, + pid: node.pid, + depth, + fold, + search: SearchRole::Ordinary, + }); + if fold.is_collapsed() { + return; + } + + for child in sorted_process_children(node, snapshot, metric, scope) { + push_process_rows(child, depth + 1, snapshot, metric, scope, policy, rows); + } +} + +fn sorted_process_children( + node: &ProcessNode, + snapshot: &Snapshot, + metric: Metric, + scope: ProcessScope, +) -> Vec { + let mut children = node.children.clone(); + children.sort_by(|lhs, rhs| { + process_cmp( + &snapshot.process_tree.nodes[*lhs], + &snapshot.process_tree.nodes[*rhs], + metric, + scope, + ) + }); + children +} + +fn push_process_search_self( + index: usize, + depth: usize, + snapshot: &Snapshot, + metric: Metric, + search: &Search, + rows: &mut Vec, + summary: &mut SearchSummary, +) { + let node = &snapshot.process_tree.nodes[index]; + if process_matches(search, node) { + summary.strike(node.rollup.metric(metric)); + rows.push(FlatProcessRow { + index, + pid: node.pid, + depth, + fold: RowFold::Leaf, + search: SearchRole::Match, + }); + } + for child in sorted_process_children(node, snapshot, metric, ProcessScope::SelfOnly) { + push_process_search_self(child, depth + 1, snapshot, metric, search, rows, summary); + } +} + +fn push_process_search_tree( + index: usize, + depth: usize, + snapshot: &Snapshot, + metric: Metric, + search: &Search, + covered_by_match: bool, + rows: &mut Vec, + summary: &mut SearchSummary, +) -> bool { + let node = &snapshot.process_tree.nodes[index]; + let direct = process_matches(search, node); + let mut child_rows = Vec::new(); + let mut child_visible = false; + let children = sorted_process_children(node, snapshot, metric, ProcessScope::SelfAndChildren); + for child in children { + child_visible |= push_process_search_tree( + child, + depth + 1, + snapshot, + metric, + search, + covered_by_match || direct, + &mut child_rows, + summary, + ); + } + if direct { + summary.hit(); + if !covered_by_match { + summary.attribute(node.subtree.metric(metric)); + } + } + let visible = direct || child_visible; + if visible { + rows.push(FlatProcessRow { + index, + pid: node.pid, + depth, + fold: if child_visible { + RowFold::Expanded + } else { + RowFold::Leaf + }, + search: if direct { + SearchRole::Match + } else { + SearchRole::Context + }, + }); + rows.extend(child_rows); + } + visible +} + +fn process_matches(search: &Search, node: &ProcessNode) -> bool { + search.matches(&node.name) + || search.matches(&node.command) + || search.matches(&node.username) + || search.matches(&node.state) + || search.matches(&node.pid.to_string()) +} + +fn largest_tmpfs_non_root_subtree(snapshot: &Snapshot) -> Bytes { + snapshot + .tmpfs_mounts + .iter() + .flat_map(|mount| mount.root.children.iter()) + .map(largest_tmpfs_subtree) + .max() + .unwrap_or(Bytes::ZERO) +} + +fn largest_tmpfs_subtree(node: &TmpfsNode) -> Bytes { + let child_max = node + .children + .iter() + .map(largest_tmpfs_subtree) + .max() + .unwrap_or(Bytes::ZERO); + if node.children.is_empty() { + child_max + } else { + child_max.max(node.allocated) + } +} + +fn push_tmpfs_rows( + mount_index: usize, + node: &TmpfsNode, + depth: usize, + policy: &FoldPolicy<'_, PathBuf>, + rows: &mut Vec, +) { + let fold = policy.row_fold(&node.path, depth, !node.children.is_empty(), node.allocated); + rows.push(FlatTmpfsRow { + mount_index, + path: node.path.clone(), + name: node.name.clone(), + kind: node.kind, + allocated: node.allocated, + logical: node.logical, + depth, + fold, + search: SearchRole::Ordinary, + }); + if fold.is_collapsed() { + return; + } + + for child in sorted_tmpfs_children(node) { + push_tmpfs_rows(mount_index, child, depth + 1, policy, rows); + } +} + +fn push_tmpfs_search_self( + mount_index: usize, + node: &TmpfsNode, + depth: usize, + search: &Search, + rows: &mut Vec, + summary: &mut SearchSummary, +) { + if tmpfs_matches(search, node) { + summary.strike(node.allocated); + rows.push(FlatTmpfsRow { + mount_index, + path: node.path.clone(), + name: node.name.clone(), + kind: node.kind, + allocated: node.allocated, + logical: node.logical, + depth, + fold: RowFold::Leaf, + search: SearchRole::Match, + }); + } + for child in sorted_tmpfs_children(node) { + push_tmpfs_search_self(mount_index, child, depth + 1, search, rows, summary); + } +} + +fn push_tmpfs_search_tree( + mount_index: usize, + node: &TmpfsNode, + depth: usize, + search: &Search, + covered_by_match: bool, + rows: &mut Vec, + summary: &mut SearchSummary, +) -> bool { + let direct = tmpfs_matches(search, node); + let mut child_rows = Vec::new(); + let mut child_visible = false; + for child in sorted_tmpfs_children(node) { + child_visible |= push_tmpfs_search_tree( + mount_index, + child, + depth + 1, + search, + covered_by_match || direct, + &mut child_rows, + summary, + ); + } + if direct { + summary.hit(); + if !covered_by_match { + summary.attribute(node.allocated); + } + } + let visible = direct || child_visible; + if visible { + rows.push(FlatTmpfsRow { + mount_index, + path: node.path.clone(), + name: node.name.clone(), + kind: node.kind, + allocated: node.allocated, + logical: node.logical, + depth, + fold: if child_visible { + RowFold::Expanded + } else { + RowFold::Leaf + }, + search: if direct { + SearchRole::Match + } else { + SearchRole::Context + }, + }); + rows.extend(child_rows); + } + visible +} + +fn sorted_tmpfs_children(node: &TmpfsNode) -> Vec<&'_ TmpfsNode> { + let mut children = node.children.iter().collect::>(); + children.sort_by(|lhs, rhs| { + rhs.allocated + .cmp(&lhs.allocated) + .then_with(|| lhs.path.cmp(&rhs.path)) + }); + children +} + +fn tmpfs_matches(search: &Search, node: &TmpfsNode) -> bool { + search.matches(&node.name) + || search.matches(node.kind.label()) + || search.matches(node.path.to_string_lossy().as_ref()) +} + +fn shared_matches(search: &Search, object: &SharedObject) -> bool { + search.matches(&object.label) || search.matches(object.kind.label()) +} + +fn process_cmp( + lhs: &ProcessNode, + rhs: &ProcessNode, + metric: Metric, + scope: ProcessScope, +) -> std::cmp::Ordering { + metric + .cmp_rollup(scope.rollup(lhs), scope.rollup(rhs)) + .then_with(|| lhs.pid.cmp(&rhs.pid)) +} diff --git a/crates/memview/src/app/tests.rs b/crates/memview/src/app/tests.rs new file mode 100644 index 0000000..1e9f8f8 --- /dev/null +++ b/crates/memview/src/app/tests.rs @@ -0,0 +1,390 @@ +use super::*; + +#[derive(Clone, Debug)] +struct TestRow(u8); + +impl IdentifiedRow for TestRow { + type Key = u8; + + fn key(&self) -> &Self::Key { + &self.0 + } +} + +fn tmpfs_mount(path: &str, allocated: Bytes) -> TmpfsMount { + TmpfsMount { + mount_point: PathBuf::from(path), + source: "tmpfs".to_string(), + size_limit: None, + root: TmpfsNode { + path: PathBuf::from(path), + name: path.to_string(), + kind: TmpfsNodeKind::Mount, + allocated, + logical: allocated, + children: Vec::new(), + }, + } +} + +fn tmpfs_tree(path: &str, allocated: Bytes, children: Vec) -> TmpfsMount { + TmpfsMount { + mount_point: PathBuf::from(path), + source: "tmpfs".to_string(), + size_limit: None, + root: TmpfsNode { + path: PathBuf::from(path), + name: path.to_string(), + kind: TmpfsNodeKind::Mount, + allocated, + logical: allocated, + children, + }, + } +} + +fn tmpfs_dir(path: &str, allocated: Bytes, children: Vec) -> TmpfsNode { + TmpfsNode { + path: PathBuf::from(path), + name: path.to_string(), + kind: TmpfsNodeKind::Directory, + allocated, + logical: allocated, + children, + } +} + +fn tmpfs_snapshot(mounts: Vec) -> Snapshot { + Snapshot { + captured_at: SystemTime::UNIX_EPOCH, + elapsed: Duration::ZERO, + meminfo: Meminfo::default(), + overview: crate::model::Overview::default(), + process_tree: crate::model::ProcessTree::default(), + shared_objects: Vec::new(), + sysv_segments: Vec::new(), + tmpfs_mounts: mounts, + warnings: Vec::new(), + } +} + +fn regex(pattern: &str) -> Search { + Search::compile(pattern.to_string()) + .expect("test regex compiles") + .expect("test regex is non-empty") +} + +fn next_process_scan_switch(commands: &Receiver) -> bool { + match commands.recv().expect("process scan switch command") { + WorkerCommand::SetProcessScanning(enabled) => enabled, + command => unreachable!("expected process scan switch, got {command:?}"), + } +} + +#[test] +fn de_minimis_chooses_lower_threshold() { + assert_eq!( + DeMinimis::from_largest_non_root(Bytes(10_000), Bytes(1_000)).threshold, + Bytes(30) + ); + assert_eq!( + DeMinimis::from_largest_non_root(Bytes(10_000), Bytes(100_000)).threshold, + Bytes(100) + ); +} + +#[test] +fn process_scanning_tracks_focus_and_active_tab() { + let (commands, events) = mpsc::channel(); + let mut app = App::new(); + + app.set_focused(false, &commands); + assert!(!next_process_scan_switch(&events)); + + app.select_tab(Tab::Processes, &commands); + assert!(!next_process_scan_switch(&events)); + + app.set_focused(true, &commands); + assert!(next_process_scan_switch(&events)); + + app.process_scan_started_at = Some(Instant::now()); + assert!(app.needs_periodic_redraw()); + app.set_focused(false, &commands); + assert!(!next_process_scan_switch(&events)); + assert!(!app.needs_periodic_redraw()); +} + +#[test] +fn fold_policy_respects_roots_leaves_and_manual_overrides() { + let collapsed = BTreeSet::new(); + let mut expanded = BTreeSet::new(); + let _ = expanded.insert(7); + let policy = FoldPolicy { + collapsed: &collapsed, + expanded: &expanded, + de_minimis: DeMinimis { + threshold: Bytes(100), + }, + }; + + assert_eq!(policy.row_fold(&1, 0, true, Bytes(1)), RowFold::Expanded); + assert_eq!(policy.row_fold(&1, 1, false, Bytes(1)), RowFold::Leaf); + assert_eq!(policy.row_fold(&1, 1, true, Bytes(99)), RowFold::Collapsed); + assert_eq!(policy.row_fold(&7, 1, true, Bytes(99)), RowFold::Expanded); +} + +#[test] +fn pane_rows_can_select_deleted_row_successor_slot() { + let mut rows = PaneRows::default(); + rows.install(vec![TestRow(1), TestRow(2), TestRow(3)]); + let _ = rows.move_by(1); + let successor = rows.selected_slot().expect("selected successor row slot"); + + rows.install(vec![TestRow(1), TestRow(3)]); + let _ = rows.select_clamped(successor); + assert_eq!(rows.selected().map(|row| row.0), Some(3)); + + rows.install(vec![TestRow(1)]); + let _ = rows.select_clamped(successor); + assert_eq!(rows.selected().map(|row| row.0), Some(1)); +} + +#[test] +fn tmpfs_background_rebuilds_stay_pinned_to_top_until_user_entry() { + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_mount("/tmpfs-small", Bytes(1))])); + app.rebuild_tmpfs_rows(); + assert_eq!( + app.tmpfs_rows.selected().map(|row| row.path.as_path()), + Some(Path::new("/tmpfs-small")) + ); + + app.snapshot = Some(tmpfs_snapshot(vec![ + tmpfs_mount("/tmpfs-big", Bytes(2)), + tmpfs_mount("/tmpfs-small", Bytes(1)), + ])); + app.rebuild_tmpfs_rows(); + assert_eq!(app.selected_tmpfs_row(), 0); + assert_eq!( + app.tmpfs_rows.selected().map(|row| row.path.as_path()), + Some(Path::new("/tmpfs-big")) + ); +} + +#[test] +fn first_tmpfs_entry_seizes_top_then_preserves_user_anchor() { + let (commands, _events) = mpsc::channel(); + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![ + tmpfs_mount("/tmpfs-big", Bytes(2)), + tmpfs_mount("/tmpfs-small", Bytes(1)), + ])); + app.rebuild_tmpfs_rows(); + let _ = app.tmpfs_rows.move_by(1); + + app.select_tab(Tab::Tmpfs, &commands); + assert_eq!(app.selected_tmpfs_row(), 0); + + app.snapshot = Some(tmpfs_snapshot(vec![ + tmpfs_mount("/tmpfs-bigger", Bytes(3)), + tmpfs_mount("/tmpfs-big", Bytes(2)), + tmpfs_mount("/tmpfs-small", Bytes(1)), + ])); + app.rebuild_tmpfs_rows(); + assert_eq!( + app.tmpfs_rows.selected().map(|row| row.path.as_path()), + Some(Path::new("/tmpfs-big")) + ); +} + +#[test] +fn optimistic_tmpfs_delete_prunes_immediately_and_keeps_successor_slot() { + let mut app = App::new(); + app.tab = Tab::Tmpfs; + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/proc/self/memview-delete-test", + Bytes(30), + vec![ + tmpfs_dir( + "/proc/self/memview-delete-test/victim", + Bytes(20), + Vec::new(), + ), + tmpfs_dir("/proc/self/memview-delete-test/next", Bytes(10), Vec::new()), + ], + )])); + app.rebuild_tmpfs_rows(); + let _ = app.tmpfs_rows.move_by(1); + + app.delete_current_tmpfs_entry(); + + assert_eq!(app.deletion_count(), 1); + assert_eq!( + app.tmpfs_rows() + .iter() + .map(|row| row.path.as_path()) + .collect::>(), + vec![ + Path::new("/proc/self/memview-delete-test"), + Path::new("/proc/self/memview-delete-test/next"), + ] + ); + assert_eq!(app.selected_tmpfs_row(), 1); +} + +#[test] +fn confirmed_tmpfs_tombstone_prunes_stale_scan_and_clears_after_absent_scan() { + let stale = tmpfs_tree( + "/tmp/memview-tombstone-test", + Bytes(30), + vec![ + tmpfs_dir("/tmp/memview-tombstone-test/victim", Bytes(20), Vec::new()), + tmpfs_dir("/tmp/memview-tombstone-test/next", Bytes(10), Vec::new()), + ], + ); + let fresh = tmpfs_tree( + "/tmp/memview-tombstone-test", + Bytes(10), + vec![tmpfs_dir( + "/tmp/memview-tombstone-test/next", + Bytes(10), + Vec::new(), + )], + ); + let victim = PathBuf::from("/tmp/memview-tombstone-test/victim"); + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![stale.clone()])); + let _ = app.confirmed_deletions.insert(victim.clone()); + + app.install_tmpfs_mount_scan(probe::TmpfsMountScan { + captured_at: SystemTime::UNIX_EPOCH, + elapsed: Duration::ZERO, + mount: stale, + warnings: Vec::new(), + }); + assert!(!app.tmpfs_rows().iter().any(|row| row.path == victim)); + assert!(app.confirmed_deletions.contains(&victim)); + + app.install_tmpfs_mount_scan(probe::TmpfsMountScan { + captured_at: SystemTime::UNIX_EPOCH, + elapsed: Duration::ZERO, + mount: fresh, + warnings: Vec::new(), + }); + assert!(!app.confirmed_deletions.contains(&victim)); +} + +#[test] +fn tmpfs_search_self_mode_filters_to_direct_matches_and_sums_them() { + let mut app = App::new(); + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/mnt", + Bytes(35), + vec![ + tmpfs_dir("/mnt/batch-a", Bytes(10), Vec::new()), + tmpfs_dir("/mnt/batch-b", Bytes(20), Vec::new()), + tmpfs_dir("/mnt/other", Bytes(5), Vec::new()), + ], + )])); + app.search = Some(regex("batch-[ab]")); + app.rebuild_tmpfs_rows(); + + assert_eq!( + app.tmpfs_rows() + .iter() + .map(|row| row.path.as_path()) + .collect::>(), + vec![Path::new("/mnt/batch-b"), Path::new("/mnt/batch-a")] + ); + assert_eq!(app.tmpfs_search.matches, 2); + assert_eq!(app.tmpfs_search.total, Bytes(30)); +} + +#[test] +fn tmpfs_search_self_and_children_includes_context_parents_without_counting_them() { + let mut app = App::new(); + app.process_scope = ProcessScope::SelfAndChildren; + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/mnt", + Bytes(35), + vec![ + tmpfs_dir("/mnt/batch-a", Bytes(10), Vec::new()), + tmpfs_dir("/mnt/batch-b", Bytes(20), Vec::new()), + tmpfs_dir("/mnt/other", Bytes(5), Vec::new()), + ], + )])); + app.search = Some(regex("batch-[ab]")); + app.rebuild_tmpfs_rows(); + + assert_eq!( + app.tmpfs_rows() + .iter() + .map(|row| (row.path.as_path(), row.search)) + .collect::>(), + vec![ + (Path::new("/mnt"), SearchRole::Context), + (Path::new("/mnt/batch-b"), SearchRole::Match), + (Path::new("/mnt/batch-a"), SearchRole::Match), + ] + ); + assert_eq!(app.tmpfs_search.matches, 2); + assert_eq!(app.tmpfs_search.total, Bytes(30)); +} + +#[test] +fn tmpfs_search_self_and_children_does_not_double_count_nested_matches() { + let mut app = App::new(); + app.process_scope = ProcessScope::SelfAndChildren; + app.snapshot = Some(tmpfs_snapshot(vec![tmpfs_tree( + "/batch-root", + Bytes(30), + vec![tmpfs_dir("/batch-root/batch-child", Bytes(10), Vec::new())], + )])); + app.search = Some(regex("batch")); + app.rebuild_tmpfs_rows(); + + assert_eq!(app.tmpfs_search.matches, 2); + assert_eq!(app.tmpfs_search.total, Bytes(30)); +} + +#[test] +fn page_rows_match_left_table_viewport_height() { + assert_eq!(PageRows::from_terminal_height(32), PageRows(23)); + assert_eq!(PageRows::from_terminal_height(9), PageRows(1)); + assert_eq!(PageRows::from_terminal_height(0), PageRows(1)); +} + +#[test] +fn page_keys_move_one_visible_pane() { + let (commands, _events) = mpsc::channel(); + let mut app = App::new(); + app.tab = Tab::Tmpfs; + app.set_terminal_height(12); + app.tmpfs_rows.install( + (0..10) + .map(|index| FlatTmpfsRow { + mount_index: 0, + path: PathBuf::from(format!("/tmp/{index}")), + name: index.to_string(), + kind: TmpfsNodeKind::File, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + depth: 0, + fold: RowFold::Leaf, + search: SearchRole::Ordinary, + }) + .collect(), + ); + + let _ = app.handle_key( + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + &commands, + ); + assert_eq!(app.selected_tmpfs_row(), 3); + + let _ = app.handle_key( + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + &commands, + ); + assert_eq!(app.selected_tmpfs_row(), 0); +} diff --git a/crates/memview/src/app/worker.rs b/crates/memview/src/app/worker.rs new file mode 100644 index 0000000..52bbc59 --- /dev/null +++ b/crates/memview/src/app/worker.rs @@ -0,0 +1,368 @@ +use crate::model::{Pid, Snapshot}; +use crate::probe; +use color_eyre::eyre::Result; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +const MIN_WORKER_REFRESH: Duration = Duration::from_secs(1); + +#[derive(Clone, Debug)] +pub enum WorkerCommand { + RefreshInventory, + RefreshProcesses, + RefreshProcessMappings(Pid), + RefreshSharedObjects, + RefreshTmpfsMounts, + RefreshTmpfsMount(PathBuf), + SetProcessScanning(bool), + Shutdown, +} + +#[derive(Debug)] +pub enum WorkerEvent { + InventoryReady(Result>), + TmpfsMountReady(Result>), + ProcessesStarted(Instant), + ProcessesReady(Result>), + ProcessMappingsReady(Result>), + SharedObjectsStarted(Instant), + SharedObjectsReady(Result>), +} + +pub fn spawn_worker(refresh_every: Duration) -> (Sender, Receiver) { + let refresh_every = refresh_every.max(MIN_WORKER_REFRESH); + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + let (inventory_tx, inventory_rx) = mpsc::channel(); + let (process_tx, process_rx) = mpsc::channel(); + + spawn_inventory_worker(inventory_rx, event_tx.clone()); + spawn_process_worker(refresh_every, process_rx, event_tx); + + let _handle = thread::spawn(move || { + while let Ok(command) = command_rx.recv() { + match command { + WorkerCommand::RefreshInventory + | WorkerCommand::RefreshTmpfsMounts + | WorkerCommand::RefreshTmpfsMount(_) => { + let _ = inventory_tx.send(command); + } + WorkerCommand::RefreshProcesses + | WorkerCommand::RefreshProcessMappings(_) + | WorkerCommand::RefreshSharedObjects + | WorkerCommand::SetProcessScanning(_) => { + let _ = process_tx.send(command); + } + WorkerCommand::Shutdown => { + let _ = inventory_tx.send(WorkerCommand::Shutdown); + let _ = process_tx.send(WorkerCommand::Shutdown); + break; + } + } + } + }); + + (command_tx, event_rx) +} + +fn spawn_inventory_worker(command_rx: Receiver, event_tx: Sender) { + let _handle = thread::spawn(move || { + loop { + match command_rx.recv() { + Ok(WorkerCommand::RefreshInventory) => { + if !publish_inventory_shell(&event_tx) || !publish_all_tmpfs_mounts(&event_tx) { + break; + } + } + Ok(WorkerCommand::RefreshTmpfsMounts) => { + if !publish_all_tmpfs_mounts(&event_tx) { + break; + } + } + Ok(WorkerCommand::RefreshTmpfsMount(path)) => { + if !publish_tmpfs_mount(&event_tx, &path) { + break; + } + } + Ok( + WorkerCommand::RefreshProcesses + | WorkerCommand::RefreshProcessMappings(_) + | WorkerCommand::RefreshSharedObjects + | WorkerCommand::SetProcessScanning(_), + ) => {} + Ok(WorkerCommand::Shutdown) | Err(_) => break, + } + } + }); +} + +#[derive(Clone, Copy, Debug)] +enum ProcessWorkerFlow { + Continue, + Shutdown, +} + +fn collect_process_command( + command: WorkerCommand, + active: &mut bool, + scan_once: &mut bool, + mapping_request: &mut Option, + shared_request: &mut bool, +) -> ProcessWorkerFlow { + match command { + WorkerCommand::SetProcessScanning(next) => *active = next, + WorkerCommand::RefreshProcesses => *scan_once = true, + WorkerCommand::RefreshProcessMappings(pid) => *mapping_request = Some(pid), + WorkerCommand::RefreshSharedObjects => *shared_request = true, + WorkerCommand::RefreshInventory + | WorkerCommand::RefreshTmpfsMounts + | WorkerCommand::RefreshTmpfsMount(_) => {} + WorkerCommand::Shutdown => return ProcessWorkerFlow::Shutdown, + } + ProcessWorkerFlow::Continue +} + +fn drain_process_commands( + command_rx: &Receiver, + active: &mut bool, + scan_once: &mut bool, + mapping_request: &mut Option, + shared_request: &mut bool, +) -> ProcessWorkerFlow { + while let Ok(command) = command_rx.try_recv() { + if matches!( + collect_process_command(command, active, scan_once, mapping_request, shared_request), + ProcessWorkerFlow::Shutdown + ) { + return ProcessWorkerFlow::Shutdown; + } + } + ProcessWorkerFlow::Continue +} + +fn publish_process_mappings(event_tx: &Sender, pid: Pid) -> ProcessWorkerFlow { + if event_tx + .send(WorkerEvent::ProcessMappingsReady( + probe::capture_process_mappings(pid).map(Box::new), + )) + .is_err() + { + ProcessWorkerFlow::Shutdown + } else { + ProcessWorkerFlow::Continue + } +} + +fn publish_shared_objects(event_tx: &Sender) -> ProcessWorkerFlow { + if event_tx + .send(WorkerEvent::SharedObjectsStarted(Instant::now())) + .is_err() + { + return ProcessWorkerFlow::Shutdown; + } + if event_tx + .send(WorkerEvent::SharedObjectsReady( + probe::capture_shared_objects().map(Box::new), + )) + .is_err() + { + ProcessWorkerFlow::Shutdown + } else { + ProcessWorkerFlow::Continue + } +} + +fn publish_processes(event_tx: &Sender) -> ProcessWorkerFlow { + if event_tx + .send(WorkerEvent::ProcessesStarted(Instant::now())) + .is_err() + { + return ProcessWorkerFlow::Shutdown; + } + if event_tx + .send(WorkerEvent::ProcessesReady( + probe::capture_processes().map(Box::new), + )) + .is_err() + { + ProcessWorkerFlow::Shutdown + } else { + ProcessWorkerFlow::Continue + } +} + +fn wait_process_command( + command_rx: &Receiver, + refresh_every: Duration, + active: &mut bool, + scan_once: &mut bool, + mapping_request: &mut Option, + shared_request: &mut bool, +) -> ProcessWorkerFlow { + match command_rx.recv_timeout(refresh_every) { + Ok(command) => { + collect_process_command(command, active, scan_once, mapping_request, shared_request) + } + Err(mpsc::RecvTimeoutError::Timeout) => ProcessWorkerFlow::Continue, + Err(mpsc::RecvTimeoutError::Disconnected) => ProcessWorkerFlow::Shutdown, + } +} + +fn publish_inventory_shell(event_tx: &Sender) -> bool { + event_tx + .send(WorkerEvent::InventoryReady( + probe::capture_inventory_shell().map(|capture| Box::new(capture.inventory_snapshot())), + )) + .is_ok() +} + +fn publish_all_tmpfs_mounts(event_tx: &Sender) -> bool { + let paths = match probe::tmpfs_mount_points() { + Ok(paths) => paths, + Err(error) => { + return event_tx + .send(WorkerEvent::TmpfsMountReady(Err(error))) + .is_ok(); + } + }; + + paths + .into_iter() + .all(|path| publish_tmpfs_mount(event_tx, &path)) +} + +fn publish_tmpfs_mount(event_tx: &Sender, path: &Path) -> bool { + event_tx + .send(WorkerEvent::TmpfsMountReady( + probe::capture_tmpfs_mount(path).map(Box::new), + )) + .is_ok() +} + +fn spawn_process_worker( + refresh_every: Duration, + command_rx: Receiver, + event_tx: Sender, +) { + let _handle = thread::spawn(move || { + let mut active = false; + let mut scan_once = false; + let mut mapping_request = None; + let mut shared_request = false; + + loop { + if !active && !scan_once && mapping_request.is_none() && !shared_request { + match command_rx.recv() { + Ok(command) => { + if matches!( + collect_process_command( + command, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) { + break; + } + } + Err(_) => break, + } + } + + if matches!( + drain_process_commands( + &command_rx, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) { + break; + } + + if let Some(pid) = mapping_request.take() { + if matches!( + publish_process_mappings(&event_tx, pid), + ProcessWorkerFlow::Shutdown + ) { + break; + } + if active + && !scan_once + && matches!( + wait_process_command( + &command_rx, + refresh_every, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) + { + break; + } + continue; + } + + if shared_request { + shared_request = false; + if matches!( + publish_shared_objects(&event_tx), + ProcessWorkerFlow::Shutdown + ) { + break; + } + if active + && !scan_once + && matches!( + wait_process_command( + &command_rx, + refresh_every, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) + { + break; + } + continue; + } + + if !active && !scan_once { + continue; + } + scan_once = false; + + if matches!(publish_processes(&event_tx), ProcessWorkerFlow::Shutdown) { + break; + } + + if active + && matches!( + wait_process_command( + &command_rx, + refresh_every, + &mut active, + &mut scan_once, + &mut mapping_request, + &mut shared_request, + ), + ProcessWorkerFlow::Shutdown + ) + { + break; + } + } + }); +} diff --git a/crates/memview/src/main.rs b/crates/memview/src/main.rs new file mode 100644 index 0000000..52580cb --- /dev/null +++ b/crates/memview/src/main.rs @@ -0,0 +1,160 @@ +#[cfg(not(target_os = "linux"))] +compile_error!("memview is Linux-only: it reads Linux /proc, tmpfs, SysV shm, and pidfd surfaces."); + +mod app; +mod model; +mod nav; +mod probe; +mod search; +mod ui; + +use crate::app::{App, WorkerCommand, spawn_worker}; +use clap::Parser; +use color_eyre::eyre::Result; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{ + self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event, + KeyEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use std::io::{self, Stdout}; +use std::time::{Duration, Instant}; + +const FOCUSED_IDLE_POLL: Duration = Duration::from_millis(500); +const BACKGROUND_IDLE_POLL: Duration = Duration::from_secs(1); +const ANIMATED_REDRAW: Duration = Duration::from_millis(250); + +#[derive(Debug, Parser)] +#[command(author, version, about = "ncdu-like RAM accounting for Linux /proc")] +struct Cli { + #[arg(long, default_value_t = 5000)] + refresh_ms: u64, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let cli = Cli::parse(); + let (commands, events) = spawn_worker(Duration::from_millis(cli.refresh_ms)); + let mut terminal = TerminalGuard::enter()?; + let mut app = App::new(); + app.set_terminal_height(terminal.height()?); + app.start_visible_work(&commands); + let mut dirty = true; + let mut next_animated_redraw = Instant::now(); + + loop { + while let Ok(event) = events.try_recv() { + app.apply_worker_event(event, &commands); + dirty = true; + } + if app.poll_deletion(&commands) { + dirty = true; + } + + let now = Instant::now(); + let redraw_animation = app.needs_periodic_redraw() && now >= next_animated_redraw; + if app.is_focused() && (dirty || redraw_animation) { + terminal.draw(|frame| ui::render(frame, &app))?; + dirty = false; + next_animated_redraw = Instant::now() + ANIMATED_REDRAW; + } + + if event::poll(poll_timeout(&app, next_animated_redraw))? { + match event::read()? { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + if app.handle_key(key, &commands) { + break; + } + dirty = true; + } + Event::Mouse(mouse) => { + if app.handle_mouse(mouse, &commands) { + dirty = true; + } + } + Event::Resize(_, height) => { + app.set_terminal_height(height); + dirty = true; + } + Event::FocusGained => { + app.set_focused(true, &commands); + dirty = true; + } + Event::FocusLost => { + app.set_focused(false, &commands); + dirty = false; + } + Event::Paste(_) => {} + } + } + } + + let _ = commands.send(WorkerCommand::Shutdown); + Ok(()) +} + +fn poll_timeout(app: &App, next_animated_redraw: Instant) -> Duration { + if !app.is_focused() { + return BACKGROUND_IDLE_POLL; + } + if app.needs_periodic_redraw() { + FOCUSED_IDLE_POLL.min(next_animated_redraw.saturating_duration_since(Instant::now())) + } else { + FOCUSED_IDLE_POLL + } +} + +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn enter() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableFocusChange, + Hide + )?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(Self { terminal }) + } + + fn draw(&mut self, draw: F) -> Result<()> + where + F: FnOnce(&mut ratatui::Frame<'_>), + { + let _ = self.terminal.draw(draw)?; + Ok(()) + } + + fn height(&self) -> Result { + Ok(self.terminal.size()?.height) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + Show, + DisableFocusChange, + DisableMouseCapture, + LeaveAlternateScreen + ); + let _ = self.terminal.show_cursor(); + } +} diff --git a/crates/memview/src/model.rs b/crates/memview/src/model.rs new file mode 100644 index 0000000..a680332 --- /dev/null +++ b/crates/memview/src/model.rs @@ -0,0 +1,480 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt::{self, Display, Formatter}; +use std::ops::{Add, AddAssign, Sub, SubAssign}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Pid(pub i32); + +impl Display for Pid { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct Bytes(pub u64); + +impl Bytes { + pub const ZERO: Self = Self(0); + const KIB: f64 = 1024.0; + const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + + #[must_use] + pub fn from_kib(kib: u64) -> Self { + Self(kib.saturating_mul(1024)) + } + + #[must_use] + pub fn from_blocks_512(blocks: u64) -> Self { + Self(blocks.saturating_mul(512)) + } + + #[must_use] + pub fn as_f64(self) -> f64 { + self.0 as f64 + } + + #[must_use] + pub fn pct_of(self, total: Self) -> f64 { + if total.0 == 0 { + 0.0 + } else { + (self.as_f64() * 100.0) / total.as_f64() + } + } + + #[must_use] + pub fn human_iec(self) -> String { + if self.0 < 1024 { + return format!("{} B", self.0); + } + + let mut value = self.as_f64(); + let mut unit = 0usize; + while value >= Self::KIB && unit + 1 < Self::UNITS.len() { + value /= Self::KIB; + unit += 1; + } + format!("{value:.1} {}", Self::UNITS[unit]) + } + + #[must_use] + pub fn human_exact(self) -> String { + format!("{} ({})", self.human_iec(), self.0) + } +} + +impl Add for Bytes { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0.saturating_add(rhs.0)) + } +} + +impl AddAssign for Bytes { + fn add_assign(&mut self, rhs: Self) { + self.0 = self.0.saturating_add(rhs.0); + } +} + +impl Sub for Bytes { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0.saturating_sub(rhs.0)) + } +} + +impl SubAssign for Bytes { + fn sub_assign(&mut self, rhs: Self) { + self.0 = self.0.saturating_sub(rhs.0); + } +} + +impl Display for Bytes { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&self.human_iec()) + } +} + +macro_rules! memory_rollup_apply { + ($lhs:expr, $rhs:expr, $op:tt) => {{ + $lhs.size $op $rhs.size; + $lhs.rss $op $rhs.rss; + $lhs.pss $op $rhs.pss; + $lhs.pss_dirty $op $rhs.pss_dirty; + $lhs.pss_anon $op $rhs.pss_anon; + $lhs.pss_file $op $rhs.pss_file; + $lhs.pss_shmem $op $rhs.pss_shmem; + $lhs.shared_clean $op $rhs.shared_clean; + $lhs.shared_dirty $op $rhs.shared_dirty; + $lhs.private_clean $op $rhs.private_clean; + $lhs.private_dirty $op $rhs.private_dirty; + $lhs.referenced $op $rhs.referenced; + $lhs.anonymous $op $rhs.anonymous; + $lhs.lazy_free $op $rhs.lazy_free; + $lhs.anon_huge_pages $op $rhs.anon_huge_pages; + $lhs.shmem_pmd_mapped $op $rhs.shmem_pmd_mapped; + $lhs.file_pmd_mapped $op $rhs.file_pmd_mapped; + $lhs.shared_hugetlb $op $rhs.shared_hugetlb; + $lhs.private_hugetlb $op $rhs.private_hugetlb; + $lhs.swap $op $rhs.swap; + $lhs.swap_pss $op $rhs.swap_pss; + $lhs.locked $op $rhs.locked; + }}; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct MemoryRollup { + pub size: Bytes, + pub rss: Bytes, + pub pss: Bytes, + pub pss_dirty: Bytes, + pub pss_anon: Bytes, + pub pss_file: Bytes, + pub pss_shmem: Bytes, + pub shared_clean: Bytes, + pub shared_dirty: Bytes, + pub private_clean: Bytes, + pub private_dirty: Bytes, + pub referenced: Bytes, + pub anonymous: Bytes, + pub lazy_free: Bytes, + pub anon_huge_pages: Bytes, + pub shmem_pmd_mapped: Bytes, + pub file_pmd_mapped: Bytes, + pub shared_hugetlb: Bytes, + pub private_hugetlb: Bytes, + pub swap: Bytes, + pub swap_pss: Bytes, + pub locked: Bytes, +} + +impl MemoryRollup { + #[must_use] + pub fn uss(self) -> Bytes { + self.private_clean + self.private_dirty + self.private_hugetlb + } + + #[must_use] + pub fn shared(self) -> Bytes { + self.shared_clean + self.shared_dirty + self.shared_hugetlb + } + + #[must_use] + pub fn metric(self, metric: Metric) -> Bytes { + match metric { + Metric::Pss => self.pss, + Metric::Uss => self.uss(), + Metric::Rss => self.rss, + Metric::SwapPss => self.swap_pss, + Metric::Anonymous => self.pss_anon.max(self.anonymous), + Metric::File => self.pss_file, + Metric::Shmem => self.pss_shmem.max(self.shared()), + } + } +} + +impl Add for MemoryRollup { + type Output = Self; + + fn add(mut self, rhs: Self) -> Self::Output { + self += rhs; + self + } +} + +impl AddAssign for MemoryRollup { + fn add_assign(&mut self, rhs: Self) { + memory_rollup_apply!(self, rhs, +=); + } +} + +#[derive(Clone, Debug)] +pub struct MeminfoEntry { + pub key: String, + pub value: Bytes, +} + +#[derive(Clone, Debug, Default)] +pub struct Meminfo { + pub entries: Vec, + pub table: BTreeMap, +} + +impl Meminfo { + #[must_use] + pub fn get(&self, key: &str) -> Bytes { + self.table.get(key).copied().unwrap_or(Bytes::ZERO) + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ObjectKind { + Anonymous, + SharedAnonymous, + Heap, + Stack, + File, + Tmpfs, + Memfd, + SysV, + Vdso, + Vvar, + Vsyscall, + Pseudo, +} + +impl ObjectKind { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Anonymous => "anon", + Self::SharedAnonymous => "shmem", + Self::Heap => "heap", + Self::Stack => "stack", + Self::File => "file", + Self::Tmpfs => "tmpfs", + Self::Memfd => "memfd", + Self::SysV => "sysv", + Self::Vdso => "vdso", + Self::Vvar => "vvar", + Self::Vsyscall => "vsyscall", + Self::Pseudo => "pseudo", + } + } +} + +#[derive(Clone, Debug)] +pub struct ObjectUsage { + pub kind: ObjectKind, + pub label: String, + pub rollup: MemoryRollup, + pub regions: usize, +} + +#[derive(Clone, Debug)] +pub struct ObjectConsumer { + pub pid: Pid, + pub name: String, + pub command: String, + pub rollup: MemoryRollup, +} + +#[derive(Clone, Debug)] +pub struct SharedObject { + pub kind: ObjectKind, + pub label: String, + pub rollup: MemoryRollup, + pub regions: usize, + pub mapped_processes: usize, + pub consumers: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LedgerState { + Exact, + Approximate, + Inaccessible, + Deferred, +} + +impl LedgerState { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Approximate => "approx", + Self::Inaccessible => "inaccessible", + Self::Deferred => "deferred", + } + } + + #[must_use] + pub fn is_inaccessible(self) -> bool { + matches!(self, Self::Approximate | Self::Inaccessible) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ProcessTreeStats { + pub observed_processes: usize, + pub inaccessible_rollups: usize, + pub inaccessible_maps: usize, +} + +#[derive(Clone, Debug)] +pub struct ProcessNode { + pub pid: Pid, + pub ppid: Option, + pub name: String, + pub command: String, + pub username: String, + pub state: String, + pub threads: u32, + pub rollup: MemoryRollup, + pub subtree: MemoryRollup, + pub children: Vec, + pub objects: Vec, + pub rollup_state: LedgerState, + pub mappings_state: LedgerState, +} + +impl ProcessNode { + #[must_use] + pub fn title(&self) -> String { + if self.command.is_empty() { + format!("{} [{}]", self.name, self.pid) + } else { + format!("{} [{}] {}", self.name, self.pid, self.command) + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct ProcessTree { + pub roots: Vec, + pub nodes: Vec, + pub stats: ProcessTreeStats, +} + +#[derive(Clone, Debug)] +pub struct SysvSegment { + pub id: i32, + pub attachments: u32, + pub owner_uid: u32, + pub size: Bytes, + pub rss: Bytes, + pub swap: Bytes, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TmpfsNodeKind { + Mount, + Directory, + File, + Symlink, + Socket, + Fifo, + CharDevice, + BlockDevice, + Other, +} + +impl TmpfsNodeKind { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Mount => "mount", + Self::Directory => "dir", + Self::File => "file", + Self::Symlink => "link", + Self::Socket => "sock", + Self::Fifo => "fifo", + Self::CharDevice => "char", + Self::BlockDevice => "block", + Self::Other => "other", + } + } +} + +#[derive(Clone, Debug)] +pub struct TmpfsNode { + pub path: PathBuf, + pub name: String, + pub kind: TmpfsNodeKind, + pub allocated: Bytes, + pub logical: Bytes, + pub children: Vec, +} + +#[derive(Clone, Debug)] +pub struct TmpfsMount { + pub mount_point: PathBuf, + pub source: String, + pub size_limit: Option, + pub root: TmpfsNode, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Metric { + Pss, + Uss, + Rss, + SwapPss, + Anonymous, + File, + Shmem, +} + +impl Metric { + const ALL: [Self; 7] = [ + Self::Pss, + Self::Uss, + Self::Rss, + Self::SwapPss, + Self::Anonymous, + Self::File, + Self::Shmem, + ]; + + #[must_use] + pub fn next(self) -> Self { + let index = Self::ALL + .iter() + .position(|metric| *metric == self) + .unwrap_or(0); + Self::ALL[(index + 1) % Self::ALL.len()] + } + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Pss => "PSS", + Self::Uss => "USS", + Self::Rss => "RSS", + Self::SwapPss => "SwapPSS", + Self::Anonymous => "Anon", + Self::File => "File", + Self::Shmem => "Shmem", + } + } + + #[must_use] + pub fn cmp_rollup(self, lhs: MemoryRollup, rhs: MemoryRollup) -> Ordering { + rhs.metric(self).cmp(&lhs.metric(self)) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Overview { + pub process_count: usize, + pub inaccessible_rollups: usize, + pub inaccessible_maps: usize, + pub process_pss_total: Bytes, + pub process_uss_total: Bytes, + pub process_rss_total: Bytes, + pub process_swap_pss_total: Bytes, + pub process_pss_anon_total: Bytes, + pub process_pss_file_total: Bytes, + pub process_pss_shmem_total: Bytes, + pub tmpfs_allocated_total: Bytes, + pub sysv_rss_total: Bytes, +} + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub meminfo: Meminfo, + pub overview: Overview, + pub process_tree: ProcessTree, + pub shared_objects: Vec, + pub sysv_segments: Vec, + pub tmpfs_mounts: Vec, + pub warnings: Vec, +} diff --git a/crates/memview/src/nav.rs b/crates/memview/src/nav.rs new file mode 100644 index 0000000..88e85a9 --- /dev/null +++ b/crates/memview/src/nav.rs @@ -0,0 +1,217 @@ +#[derive(Clone, Copy, Debug)] +pub struct Hotkey { + pub key: &'static str, + pub action: &'static str, +} + +#[derive(Clone, Copy, Debug)] +pub struct HotkeySections { + pub global: &'static [Hotkey], + pub pane_title: &'static str, + pub pane: &'static [Hotkey], +} + +const GLOBAL_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "1-4 / Tab", + action: "switch pane", + }, + Hotkey { + key: "?", + action: "show this help", + }, + Hotkey { + key: "/", + action: "filter current ledger rows by regexp; empty search clears", + }, + Hotkey { + key: "f", + action: "clear active regexp filter", + }, + Hotkey { + key: "Esc", + action: "no-op at top level; close modal/help", + }, + Hotkey { + key: "q / Ctrl-C", + action: "quit", + }, +]; + +const OVERVIEW_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "r", + action: "refresh kernel counters, tmpfs mounts, and SysV shm", + }, + Hotkey { + key: "s", + action: "cycle memory lens", + }, +]; + +const PROCESS_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "j/k / arrows", + action: "move selection", + }, + Hotkey { + key: "gg / G", + action: "jump to first or last row", + }, + Hotkey { + key: "PgUp / PgDn", + action: "move selection by one visible pane", + }, + Hotkey { + key: "wheel", + action: "move selection one row per detent", + }, + Hotkey { + key: "h/l / Left/Right", + action: "collapse or expand selected process", + }, + Hotkey { + key: "Enter", + action: "toggle selected process fold, including auto-folds", + }, + Hotkey { + key: "s", + action: "cycle sort metric", + }, + Hotkey { + key: "m", + action: "toggle self vs self+children accounting", + }, + Hotkey { + key: "r", + action: "force an immediate process memory rescan", + }, + Hotkey { + key: "K", + action: "confirm SIGTERM for selected process", + }, +]; + +const TMPFS_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "j/k / arrows", + action: "move selection", + }, + Hotkey { + key: "gg / G", + action: "jump to first or last row", + }, + Hotkey { + key: "PgUp / PgDn", + action: "move selection by one visible pane", + }, + Hotkey { + key: "wheel", + action: "move selection one row per detent", + }, + Hotkey { + key: "h/l / Left/Right", + action: "collapse or expand selected entry", + }, + Hotkey { + key: "Enter", + action: "toggle selected directory fold, including auto-folds", + }, + Hotkey { + key: "m", + action: "toggle self vs self+children search context", + }, + Hotkey { + key: "r", + action: "refresh only the selected tmpfs mount", + }, + Hotkey { + key: "d", + action: "delete selected tmpfs entry recursively if directory", + }, +]; + +const SHARED_HOTKEYS: &[Hotkey] = &[ + Hotkey { + key: "j/k / arrows", + action: "move selection", + }, + Hotkey { + key: "gg / G", + action: "jump to first or last row", + }, + Hotkey { + key: "PgUp / PgDn", + action: "move selection by one visible pane", + }, + Hotkey { + key: "wheel", + action: "move selection one row per detent", + }, + Hotkey { + key: "s", + action: "cycle memory lens", + }, + Hotkey { + key: "m", + action: "toggle self vs self+children search context", + }, + Hotkey { + key: "r", + action: "force an immediate shared-object rescan", + }, +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tab { + Overview, + Processes, + Tmpfs, + Shared, +} + +impl Tab { + pub const ALL: [Self; 4] = [Self::Overview, Self::Processes, Self::Tmpfs, Self::Shared]; + + #[must_use] + pub fn next(self) -> Self { + let index = Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0); + Self::ALL[(index + 1) % Self::ALL.len()] + } + + #[must_use] + pub fn previous(self) -> Self { + let index = Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0); + Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()] + } + + #[must_use] + pub fn title(self) -> &'static str { + match self { + Self::Overview => "Overview", + Self::Processes => "Processes", + Self::Tmpfs => "Tmpfs", + Self::Shared => "Shared", + } + } + + #[must_use] + pub fn drives_process_scans(self) -> bool { + matches!(self, Self::Processes) + } + + #[must_use] + pub fn hotkeys(self) -> &'static [Hotkey] { + match self { + Self::Overview => OVERVIEW_HOTKEYS, + Self::Processes => PROCESS_HOTKEYS, + Self::Tmpfs => TMPFS_HOTKEYS, + Self::Shared => SHARED_HOTKEYS, + } + } +} + +#[must_use] +pub fn global_hotkeys() -> &'static [Hotkey] { + GLOBAL_HOTKEYS +} diff --git a/crates/memview/src/probe.rs b/crates/memview/src/probe.rs new file mode 100644 index 0000000..1825ecb --- /dev/null +++ b/crates/memview/src/probe.rs @@ -0,0 +1,1417 @@ +use crate::model::{ + Bytes, LedgerState, Meminfo, MeminfoEntry, MemoryRollup, Metric, ObjectConsumer, ObjectKind, + ObjectUsage, Overview, Pid, ProcessNode, ProcessTree, ProcessTreeStats, SharedObject, Snapshot, + SysvSegment, TmpfsMount, TmpfsNode, TmpfsNodeKind, +}; +use color_eyre::eyre::{Context, Result, eyre}; +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::ffi::OsStr; +use std::fs::{self, Metadata}; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; +use uzers::get_user_by_uid; +use walkdir::WalkDir; + +const DELETED_MAPPING_SUFFIX: &str = " (deleted)"; + +pub struct Capture { + started: Instant, + meminfo: Meminfo, + tmpfs_mounts: Vec, + sysv_segments: Vec, + warnings: Vec, +} + +impl Capture { + #[must_use] + pub fn inventory_snapshot(&self) -> Snapshot { + let process_tree = ProcessTree::default(); + let shared_objects = fold_shared_objects(&process_tree, &self.sysv_segments); + let overview = derive_overview(&process_tree, &self.tmpfs_mounts, &self.sysv_segments); + + Snapshot { + captured_at: SystemTime::now(), + elapsed: self.started.elapsed(), + meminfo: self.meminfo.clone(), + overview, + process_tree, + shared_objects, + sysv_segments: self.sysv_segments.clone(), + tmpfs_mounts: self.tmpfs_mounts.clone(), + warnings: self.warnings.clone(), + } + } +} + +#[derive(Debug)] +pub struct ProcessScan { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub meminfo: Meminfo, + pub process_tree: ProcessTree, + pub warnings: Vec, +} + +#[derive(Debug)] +pub struct ProcessMappingScan { + pub elapsed: Duration, + pub cost: ProcessMappingCost, + pub pid: Pid, + pub objects: Vec, + pub mappings_state: LedgerState, + pub warnings: Vec, +} + +#[derive(Clone, Copy, Debug)] +pub struct ProcessMappingCost { + pub mount_index: Duration, + pub read: Duration, + pub parse: Duration, +} + +#[derive(Debug)] +pub struct SharedObjectsScan { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub meminfo: Meminfo, + pub shared_objects: Vec, + pub warnings: Vec, +} + +#[derive(Debug)] +pub struct TmpfsMountScan { + pub captured_at: SystemTime, + pub elapsed: Duration, + pub mount: TmpfsMount, + pub warnings: Vec, +} + +impl ProcessScan { + pub fn install(self, snapshot: &mut Snapshot) { + snapshot.captured_at = self.captured_at; + snapshot.elapsed = self.elapsed; + snapshot.meminfo = self.meminfo; + snapshot.process_tree = self.process_tree; + rebuild_snapshot_derived(snapshot); + } +} + +pub fn capture_inventory_shell() -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let meminfo = read_meminfo().wrap_err("failed to read /proc/meminfo")?; + let _mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let sysv_segments = + read_sysv_segments(&mut warnings).wrap_err("failed to read /proc/sysvipc/shm")?; + + Ok(Capture { + started, + meminfo, + tmpfs_mounts: Vec::new(), + sysv_segments, + warnings, + }) +} + +pub fn capture_processes() -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let meminfo = read_meminfo().wrap_err("failed to read /proc/meminfo")?; + let forest = scan_processes(&mut warnings).wrap_err("failed to scan /proc")?; + Ok(ProcessScan { + captured_at: SystemTime::now(), + elapsed: started.elapsed(), + meminfo, + process_tree: build_process_tree(forest.processes, forest.stats), + warnings, + }) +} + +pub fn capture_process_mappings(pid: Pid) -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let mount_started = Instant::now(); + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let mount_index_elapsed = mount_started.elapsed(); + let root = PathBuf::from("/proc").join(pid.0.to_string()); + let read_started = Instant::now(); + let (objects, mappings_state, read_elapsed, parse_elapsed) = + match fs::read_to_string(root.join("smaps")) { + Ok(text) => { + let read_elapsed = read_started.elapsed(); + let parse_started = Instant::now(); + let objects = parse_smaps(&text, &mount_index); + ( + objects, + LedgerState::Exact, + read_elapsed, + parse_started.elapsed(), + ) + } + Err(error) => { + warnings.push(format!( + "selected process mappings unavailable for {pid}: {error}" + )); + ( + Vec::new(), + LedgerState::Inaccessible, + read_started.elapsed(), + Duration::ZERO, + ) + } + }; + + Ok(ProcessMappingScan { + elapsed: started.elapsed(), + cost: ProcessMappingCost { + mount_index: mount_index_elapsed, + read: read_elapsed, + parse: parse_elapsed, + }, + pid, + objects, + mappings_state, + warnings, + }) +} + +pub fn capture_shared_objects() -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let meminfo = read_meminfo().wrap_err("failed to read /proc/meminfo")?; + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let sysv_segments = + read_sysv_segments(&mut warnings).wrap_err("failed to read /proc/sysvipc/shm")?; + let mut processes = scan_process_shells(&mut warnings).wrap_err("failed to scan /proc")?; + attach_all_mapping_ledgers(&mut processes, &mount_index); + let stats = process_stats(&processes); + let process_tree = build_process_tree(processes, stats); + + Ok(SharedObjectsScan { + captured_at: SystemTime::now(), + elapsed: started.elapsed(), + meminfo, + shared_objects: fold_shared_objects(&process_tree, &sysv_segments), + warnings, + }) +} + +pub fn tmpfs_mount_points() -> Result> { + let mut warnings = Vec::new(); + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + Ok(unique_tmpfs_infos(&mount_index, &mut warnings) + .into_iter() + .map(|info| info.mount_point) + .collect()) +} + +pub fn capture_tmpfs_mount(path: &Path) -> Result { + let started = Instant::now(); + let mut warnings = Vec::new(); + let mount_index = MountIndex::read().wrap_err("failed to read /proc/self/mountinfo")?; + let info = mount_index + .match_tmpfs_mount(path) + .cloned() + .ok_or_else(|| eyre!("no tmpfs mount contains {}", path.display()))?; + let mount = scan_tmpfs_mount(&info).map_err(|error| { + warnings.push(format!( + "tmpfs scan skipped for {}: {error}", + info.mount_point.display() + )); + error + })?; + + Ok(TmpfsMountScan { + captured_at: SystemTime::now(), + elapsed: started.elapsed(), + mount, + warnings, + }) +} + +pub fn rebuild_snapshot_derived(snapshot: &mut Snapshot) { + snapshot.overview = derive_overview( + &snapshot.process_tree, + &snapshot.tmpfs_mounts, + &snapshot.sysv_segments, + ); +} + +#[derive(Clone, Debug)] +struct MountInfo { + mount_point: PathBuf, + fs_type: String, + source: String, + super_options: String, +} + +#[derive(Clone, Debug, Default)] +struct MountIndex { + tmpfs: Vec, +} + +#[derive(Clone, Debug)] +struct TmpfsBuilder { + path: PathBuf, + name: String, + kind: TmpfsNodeKind, + own_allocated: Bytes, + own_logical: Bytes, + allocated: Bytes, + logical: Bytes, + children: Vec, +} + +impl MountIndex { + fn read() -> Result { + let text = fs::read_to_string("/proc/self/mountinfo")?; + let mut tmpfs_by_mountpoint = BTreeMap::new(); + + for line in text.lines().filter(|line| !line.is_empty()) { + let Some(info) = parse_mountinfo_line(line) else { + continue; + }; + if info.fs_type == "tmpfs" { + let _ = tmpfs_by_mountpoint + .entry(info.mount_point.clone()) + .or_insert(info); + } + } + let mut tmpfs = tmpfs_by_mountpoint.into_values().collect::>(); + tmpfs.sort_by_key(|info| Reverse(info.mount_point.as_os_str().len())); + Ok(Self { tmpfs }) + } + + fn match_tmpfs_mount<'a>(&'a self, path: &Path) -> Option<&'a MountInfo> { + self.tmpfs.iter().find(|info| { + path == info.mount_point + || path + .strip_prefix(&info.mount_point) + .is_ok_and(|suffix| !suffix.as_os_str().is_empty()) + }) + } +} + +fn parse_mountinfo_line(line: &str) -> Option { + let (left, right) = line.split_once(" - ")?; + let left_fields = left.split_whitespace().collect::>(); + let right_fields = right.split_whitespace().collect::>(); + if left_fields.len() < 5 || right_fields.len() < 3 { + return None; + } + + Some(MountInfo { + mount_point: PathBuf::from(unescape_mount_field(left_fields[4])), + fs_type: right_fields[0].to_string(), + source: right_fields[1].to_string(), + super_options: right_fields[2..].join(" "), + }) +} + +fn unescape_mount_field(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0usize; + + while index < bytes.len() { + if bytes[index] == b'\\' && index + 3 < bytes.len() { + let slice = &value[index + 1..index + 4]; + if let Ok(code) = u8::from_str_radix(slice, 8) { + out.push(char::from(code)); + index += 4; + continue; + } + } + + out.push(bytes[index].into()); + index += 1; + } + + out +} + +fn read_meminfo() -> Result { + let text = fs::read_to_string("/proc/meminfo")?; + Ok(parse_meminfo(&text)) +} + +fn parse_meminfo(text: &str) -> Meminfo { + #[derive(Clone, Debug)] + struct RawEntry<'a> { + key: &'a str, + number: u64, + unit: Option<&'a str>, + } + + let raw = text + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let (key, rest) = line.split_once(':')?; + let mut fields = rest.split_whitespace(); + Some(RawEntry { + key: key.trim(), + number: fields.next()?.parse().ok()?, + unit: fields.next(), + }) + }) + .collect::>(); + let hugepage_size = raw + .iter() + .find(|entry| entry.key == "Hugepagesize") + .map(|entry| Bytes::from_kib(entry.number)) + .unwrap_or(Bytes::ZERO); + let mut entries = Vec::new(); + let mut table = BTreeMap::new(); + + for entry in raw { + let value = meminfo_value(entry.key, entry.number, entry.unit, hugepage_size); + entries.push(MeminfoEntry { + key: entry.key.to_string(), + value, + }); + let _ = table.insert(entry.key.to_string(), value); + } + + Meminfo { entries, table } +} + +fn meminfo_value(key: &str, number: u64, unit: Option<&str>, hugepage_size: Bytes) -> Bytes { + if key.starts_with("HugePages_") { + return Bytes(number.saturating_mul(hugepage_size.0)); + } + + match unit { + Some("kB") => Bytes::from_kib(number), + _ => Bytes(number), + } +} + +fn read_sysv_segments(warnings: &mut Vec) -> Result> { + let text = match fs::read_to_string("/proc/sysvipc/shm") { + Ok(text) => text, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + + let mut lines = text.lines(); + let Some(header) = lines.next() else { + return Ok(Vec::new()); + }; + let columns = header.split_whitespace().collect::>(); + let index = columns + .iter() + .enumerate() + .map(|(position, name)| (*name, position)) + .collect::>(); + + let mut segments = Vec::new(); + for line in lines.filter(|line| !line.trim().is_empty()) { + let fields = line.split_whitespace().collect::>(); + let Some(id) = parse_column::(&fields, &index, "shmid") else { + warnings.push(format!("ignoring malformed sysv shm row: {line}")); + continue; + }; + + segments.push(SysvSegment { + id, + attachments: parse_column::(&fields, &index, "nattch").unwrap_or(0), + owner_uid: parse_column::(&fields, &index, "uid").unwrap_or(0), + size: parse_column::(&fields, &index, "size") + .map(Bytes) + .unwrap_or(Bytes::ZERO), + rss: parse_column::(&fields, &index, "rss") + .map(Bytes) + .unwrap_or(Bytes::ZERO), + swap: parse_column::(&fields, &index, "swap") + .map(Bytes) + .unwrap_or(Bytes::ZERO), + }); + } + + segments.sort_by(|lhs, rhs| rhs.rss.cmp(&lhs.rss).then_with(|| lhs.id.cmp(&rhs.id))); + Ok(segments) +} + +fn parse_column( + fields: &[&str], + index: &HashMap<&str, usize>, + name: &str, +) -> Option { + let position = *index.get(name)?; + fields.get(position)?.parse().ok() +} + +#[derive(Clone, Debug)] +struct ScannedProcess { + pid: Pid, + ppid: Option, + name: String, + command: String, + username: String, + state: String, + threads: u32, + rollup: MemoryRollup, + objects: Vec, + rollup_state: LedgerState, + mappings_state: LedgerState, +} + +fn scan_process_shells(warnings: &mut Vec) -> Result> { + let mut processes = Vec::new(); + let mut usernames = BTreeMap::new(); + + for entry in fs::read_dir("/proc")? { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + warnings.push(format!("ignoring /proc entry: {error}")); + continue; + } + }; + + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { + continue; + }; + + match scan_process_shell(Pid(pid), &mut usernames) { + Ok(Some(process)) => processes.push(process), + Ok(None) => {} + Err(error) => warnings.push(format!("ignoring pid {pid}: {error}")), + } + } + + Ok(processes) +} + +fn scan_processes(warnings: &mut Vec) -> Result { + let mut processes = scan_process_shells(warnings)?; + let stats = process_stats(&processes); + processes.sort_by_key(|process| process.pid); + Ok(ProcessForest { processes, stats }) +} + +fn scan_process_shell( + pid: Pid, + usernames: &mut BTreeMap, +) -> Result> { + let root = PathBuf::from("/proc").join(pid.0.to_string()); + let status_text = match fs::read_to_string(root.join("status")) { + Ok(text) => text, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) => + { + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + + let status = parse_status(&status_text); + let command = read_cmdline(&root).unwrap_or_else(|| status.name.clone()); + let username = lookup_username(status.uid, usernames); + let fallback_rollup = MemoryRollup { + rss: status.vm_rss, + pss: status.vm_rss, + anonymous: status.rss_anon, + pss_anon: status.rss_anon, + pss_file: status.rss_file, + pss_shmem: status.rss_shmem, + swap: status.vm_swap, + ..MemoryRollup::default() + }; + let (rollup, rollup_state) = match fs::read_to_string(root.join("smaps_rollup")) { + Ok(text) => (parse_rollup_kv(&text), LedgerState::Exact), + Err(_) => (fallback_rollup, LedgerState::Approximate), + }; + + Ok(Some(ScannedProcess { + pid, + ppid: status.ppid, + name: status.name, + command, + username, + state: status.state, + threads: status.threads, + rollup, + objects: Vec::new(), + rollup_state, + mappings_state: LedgerState::Deferred, + })) +} + +#[derive(Clone, Debug)] +struct ProcessForest { + processes: Vec, + stats: ProcessTreeStats, +} + +fn process_stats(processes: &[ScannedProcess]) -> ProcessTreeStats { + ProcessTreeStats { + observed_processes: processes.len(), + inaccessible_rollups: processes + .iter() + .filter(|process| process.rollup_state.is_inaccessible()) + .count(), + inaccessible_maps: processes + .iter() + .filter(|process| process.mappings_state.is_inaccessible()) + .count(), + } +} + +fn attach_all_mapping_ledgers(processes: &mut [ScannedProcess], mount_index: &MountIndex) { + for process in processes.iter_mut() { + if process.rollup.rss == Bytes::ZERO && process.rollup.pss == Bytes::ZERO { + continue; + } + match fs::read_to_string( + PathBuf::from("/proc") + .join(process.pid.0.to_string()) + .join("smaps"), + ) { + Ok(text) => { + process.objects = parse_smaps(&text, mount_index); + process.mappings_state = LedgerState::Exact; + } + Err(_) => process.mappings_state = LedgerState::Inaccessible, + } + } +} + +#[derive(Clone, Debug)] +struct StatusSnapshot { + name: String, + ppid: Option, + uid: u32, + state: String, + threads: u32, + vm_rss: Bytes, + vm_swap: Bytes, + rss_anon: Bytes, + rss_file: Bytes, + rss_shmem: Bytes, +} + +fn parse_status(text: &str) -> StatusSnapshot { + let mut name = String::new(); + let mut ppid = None; + let mut uid = 0u32; + let mut state = "?".to_string(); + let mut threads = 0u32; + let mut vm_rss = Bytes::ZERO; + let mut vm_swap = Bytes::ZERO; + let mut rss_anon = Bytes::ZERO; + let mut rss_file = Bytes::ZERO; + let mut rss_shmem = Bytes::ZERO; + + for line in text.lines().filter(|line| !line.is_empty()) { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match key { + "Name" => name = value.to_string(), + "PPid" => ppid = value.parse::().ok().map(Pid), + "Uid" => { + uid = value + .split_whitespace() + .next() + .and_then(|field| field.parse().ok()) + .unwrap_or(0); + } + "State" => state = value.to_string(), + "Threads" => threads = value.parse().unwrap_or(0), + "VmRSS" => vm_rss = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "VmSwap" => vm_swap = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "RssAnon" => rss_anon = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "RssFile" => rss_file = parse_status_kib(value).unwrap_or(Bytes::ZERO), + "RssShmem" => rss_shmem = parse_status_kib(value).unwrap_or(Bytes::ZERO), + _ => {} + } + } + + StatusSnapshot { + name, + ppid, + uid, + state, + threads, + vm_rss, + vm_swap, + rss_anon, + rss_file, + rss_shmem, + } +} + +fn parse_status_kib(value: &str) -> Option { + value + .split_whitespace() + .next() + .and_then(|field| field.parse::().ok()) + .map(Bytes::from_kib) +} + +fn read_cmdline(root: &Path) -> Option { + let bytes = fs::read(root.join("cmdline")).ok()?; + if bytes.is_empty() { + return None; + } + + let parts = bytes + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +fn lookup_username(uid: u32, cache: &mut BTreeMap) -> String { + cache + .entry(uid) + .or_insert_with(|| { + get_user_by_uid(uid) + .map(|user| String::from_utf8_lossy(user.name().as_bytes()).into_owned()) + .unwrap_or_else(|| uid.to_string()) + }) + .clone() +} + +fn parse_rollup_kv(text: &str) -> MemoryRollup { + let mut rollup = MemoryRollup::default(); + + for line in text.lines().skip(1) { + let Some((key, value)) = parse_kib_value(line) else { + continue; + }; + apply_rollup_field(&mut rollup, key, value); + } + + rollup +} + +fn parse_kib_value(line: &str) -> Option<(&str, Bytes)> { + let (key, rest) = line.split_once(':')?; + let value = rest.split_whitespace().next()?.parse::().ok()?; + Some((key.trim(), Bytes::from_kib(value))) +} + +fn apply_rollup_field(rollup: &mut MemoryRollup, key: &str, value: Bytes) { + match key { + "Size" => rollup.size = value, + "Rss" => rollup.rss = value, + "Pss" => rollup.pss = value, + "Pss_Dirty" => rollup.pss_dirty = value, + "Pss_Anon" => rollup.pss_anon = value, + "Pss_File" => rollup.pss_file = value, + "Pss_Shmem" => rollup.pss_shmem = value, + "Shared_Clean" => rollup.shared_clean = value, + "Shared_Dirty" => rollup.shared_dirty = value, + "Private_Clean" => rollup.private_clean = value, + "Private_Dirty" => rollup.private_dirty = value, + "Referenced" => rollup.referenced = value, + "Anonymous" => rollup.anonymous = value, + "LazyFree" => rollup.lazy_free = value, + "AnonHugePages" => rollup.anon_huge_pages = value, + "ShmemPmdMapped" => rollup.shmem_pmd_mapped = value, + "FilePmdMapped" => rollup.file_pmd_mapped = value, + "Shared_Hugetlb" => rollup.shared_hugetlb = value, + "Private_Hugetlb" => rollup.private_hugetlb = value, + "Swap" => rollup.swap = value, + "SwapPss" => rollup.swap_pss = value, + "Locked" => rollup.locked = value, + _ => {} + } +} + +fn parse_smaps(text: &str, mount_index: &MountIndex) -> Vec { + let mut objects = BTreeMap::<(ObjectKind, String), ObjectUsage>::new(); + let mut current = None::; + + for line in text.lines() { + if let Some(header) = parse_mapping_header(line) { + flush_mapping(&mut current, &mut objects); + current = Some(MappingAccumulator::new( + classify_mapping(&header.path, mount_index), + header.size, + )); + continue; + } + + if let Some((key, value)) = parse_kib_value(line) + && let Some(mapping) = current.as_mut() + { + apply_rollup_field(&mut mapping.rollup, key, value); + } + } + + flush_mapping(&mut current, &mut objects); + let mut rows = objects.into_values().collect::>(); + rows.sort_by(|lhs, rhs| { + Metric::Pss + .cmp_rollup(lhs.rollup, rhs.rollup) + .then_with(|| lhs.label.cmp(&rhs.label)) + }); + rows +} + +fn flush_mapping( + current: &mut Option, + objects: &mut BTreeMap<(ObjectKind, String), ObjectUsage>, +) { + let Some(mapping) = current.take() else { + return; + }; + + let key = (mapping.kind, mapping.label.clone()); + let entry = objects.entry(key).or_insert_with(|| ObjectUsage { + kind: mapping.kind, + label: mapping.label.clone(), + rollup: MemoryRollup::default(), + regions: 0, + }); + entry.rollup += mapping.rollup; + entry.regions += 1; +} + +#[derive(Clone, Debug)] +struct MappingAccumulator { + kind: ObjectKind, + label: String, + rollup: MemoryRollup, +} + +impl MappingAccumulator { + fn new(classified: ClassifiedMapping, size: Bytes) -> Self { + Self { + kind: classified.kind, + label: classified.label, + rollup: MemoryRollup { + size, + ..MemoryRollup::default() + }, + } + } +} + +#[derive(Clone, Debug)] +struct MappingHeader { + size: Bytes, + path: String, +} + +fn parse_mapping_header(line: &str) -> Option { + let mut cursor = 0usize; + let range = take_field(line, &mut cursor)?; + let _perms = take_field(line, &mut cursor)?; + let _offset = take_field(line, &mut cursor)?; + let _dev = take_field(line, &mut cursor)?; + let _inode = take_field(line, &mut cursor)?; + let path = line[cursor..].trim().to_string(); + + let (start, end) = range.split_once('-')?; + let start = u64::from_str_radix(start, 16).ok()?; + let end = u64::from_str_radix(end, 16).ok()?; + + Some(MappingHeader { + size: Bytes(end.saturating_sub(start)), + path, + }) +} + +fn take_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> { + let bytes = line.as_bytes(); + while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() { + *cursor += 1; + } + if *cursor >= bytes.len() { + return None; + } + let start = *cursor; + while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() { + *cursor += 1; + } + Some(&line[start..*cursor]) +} + +#[derive(Clone, Debug)] +struct ClassifiedMapping { + kind: ObjectKind, + label: String, +} + +fn classify_mapping(path: &str, mount_index: &MountIndex) -> ClassifiedMapping { + if path.is_empty() { + return ClassifiedMapping { + kind: ObjectKind::Anonymous, + label: "".to_string(), + }; + } + + let mut raw = path.to_string(); + let deleted = raw.ends_with(DELETED_MAPPING_SUFFIX); + if deleted { + raw.truncate(raw.len().saturating_sub(DELETED_MAPPING_SUFFIX.len())); + } + + if raw.starts_with('[') && raw.ends_with(']') { + let inner = &raw[1..raw.len() - 1]; + return match inner { + "heap" => ClassifiedMapping { + kind: ObjectKind::Heap, + label: "[heap]".to_string(), + }, + "vdso" => ClassifiedMapping { + kind: ObjectKind::Vdso, + label: "[vdso]".to_string(), + }, + "vvar" => ClassifiedMapping { + kind: ObjectKind::Vvar, + label: "[vvar]".to_string(), + }, + "vsyscall" => ClassifiedMapping { + kind: ObjectKind::Vsyscall, + label: "[vsyscall]".to_string(), + }, + _ if inner.starts_with("stack") => ClassifiedMapping { + kind: ObjectKind::Stack, + label: raw, + }, + _ if inner.starts_with("anon_shmem:") => ClassifiedMapping { + kind: ObjectKind::SharedAnonymous, + label: raw, + }, + _ if inner.starts_with("anon:") => ClassifiedMapping { + kind: ObjectKind::Anonymous, + label: raw, + }, + _ => ClassifiedMapping { + kind: ObjectKind::Pseudo, + label: raw, + }, + }; + } + + if raw.starts_with("/SYSV") { + return ClassifiedMapping { + kind: ObjectKind::SysV, + label: restore_deleted_suffix(raw, deleted), + }; + } + + if raw.starts_with("/memfd:") { + return ClassifiedMapping { + kind: ObjectKind::Memfd, + label: restore_deleted_suffix(raw, deleted), + }; + } + + let path = Path::new(&raw); + if mount_index.match_tmpfs_mount(path).is_some() { + return ClassifiedMapping { + kind: ObjectKind::Tmpfs, + label: restore_deleted_suffix(raw, deleted), + }; + } + + ClassifiedMapping { + kind: ObjectKind::File, + label: restore_deleted_suffix(raw, deleted), + } +} + +fn restore_deleted_suffix(raw: String, deleted: bool) -> String { + if deleted { + format!("{raw}{DELETED_MAPPING_SUFFIX}") + } else { + raw + } +} + +fn build_process_tree(processes: Vec, stats: ProcessTreeStats) -> ProcessTree { + let mut nodes = processes + .into_iter() + .map(|process| ProcessNode { + pid: process.pid, + ppid: process.ppid, + name: process.name, + command: process.command, + username: process.username, + state: process.state, + threads: process.threads, + rollup: process.rollup, + subtree: process.rollup, + children: Vec::new(), + objects: process.objects, + rollup_state: process.rollup_state, + mappings_state: process.mappings_state, + }) + .collect::>(); + + let by_pid = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.pid, index)) + .collect::>(); + + let mut roots = Vec::new(); + for index in 0..nodes.len() { + let Some(ppid) = nodes[index].ppid else { + roots.push(index); + continue; + }; + match by_pid.get(&ppid).copied() { + Some(parent) if parent != index => nodes[parent].children.push(index), + _ => roots.push(index), + } + } + + for root in roots.clone() { + let _ = accumulate_subtree(root, &mut nodes); + } + + ProcessTree { + roots, + nodes, + stats, + } +} + +fn accumulate_subtree(index: usize, nodes: &mut [ProcessNode]) -> MemoryRollup { + let children = nodes[index].children.clone(); + let mut subtotal = nodes[index].rollup; + for child in children { + subtotal += accumulate_subtree(child, nodes); + } + nodes[index].subtree = subtotal; + subtotal +} + +fn fold_shared_objects( + process_tree: &ProcessTree, + sysv_segments: &[SysvSegment], +) -> Vec { + struct Accumulator { + kind: ObjectKind, + label: String, + rollup: MemoryRollup, + regions: usize, + consumers: Vec, + } + + let mut objects = BTreeMap::<(ObjectKind, String), Accumulator>::new(); + + for node in &process_tree.nodes { + for object in &node.objects { + let entry = objects + .entry((object.kind, object.label.clone())) + .or_insert_with(|| Accumulator { + kind: object.kind, + label: object.label.clone(), + rollup: MemoryRollup::default(), + regions: 0, + consumers: Vec::new(), + }); + entry.rollup += object.rollup; + entry.regions += object.regions; + entry.consumers.push(ObjectConsumer { + pid: node.pid, + name: node.name.clone(), + command: node.command.clone(), + rollup: object.rollup, + }); + } + } + + let mut rows = objects + .into_values() + .map(|mut acc| { + acc.consumers.sort_by(|lhs, rhs| { + Metric::Pss + .cmp_rollup(lhs.rollup, rhs.rollup) + .then_with(|| lhs.pid.cmp(&rhs.pid)) + }); + SharedObject { + kind: acc.kind, + label: acc.label, + rollup: acc.rollup, + regions: acc.regions, + mapped_processes: acc.consumers.len(), + consumers: acc.consumers, + } + }) + .collect::>(); + + for segment in sysv_segments { + rows.push(SharedObject { + kind: ObjectKind::SysV, + label: format!( + "sysv:{} owner:{} attaches:{} size:{}", + segment.id, + segment.owner_uid, + segment.attachments, + segment.size.human_iec() + ), + rollup: MemoryRollup { + rss: segment.rss, + swap: segment.swap, + ..MemoryRollup::default() + }, + regions: 1, + mapped_processes: segment.attachments as usize, + consumers: Vec::new(), + }); + } + + rows.sort_by(|lhs, rhs| { + Metric::Pss + .cmp_rollup(lhs.rollup, rhs.rollup) + .then_with(|| rhs.rollup.rss.cmp(&lhs.rollup.rss)) + .then_with(|| lhs.label.cmp(&rhs.label)) + }); + rows +} + +fn derive_overview( + process_tree: &ProcessTree, + tmpfs_mounts: &[TmpfsMount], + sysv_segments: &[SysvSegment], +) -> Overview { + let mut overview = Overview { + process_count: process_tree.stats.observed_processes, + inaccessible_rollups: process_tree.stats.inaccessible_rollups, + inaccessible_maps: process_tree.stats.inaccessible_maps, + ..Overview::default() + }; + + for node in &process_tree.nodes { + overview.process_pss_total += node.rollup.pss; + overview.process_uss_total += node.rollup.uss(); + overview.process_rss_total += node.rollup.rss; + overview.process_swap_pss_total += node.rollup.swap_pss; + overview.process_pss_anon_total += node.rollup.pss_anon; + overview.process_pss_file_total += node.rollup.pss_file; + overview.process_pss_shmem_total += node.rollup.pss_shmem; + } + + for mount in tmpfs_mounts { + overview.tmpfs_allocated_total += mount.root.allocated; + } + for segment in sysv_segments { + overview.sysv_rss_total += segment.rss; + } + + overview +} + +fn unique_tmpfs_infos(mount_index: &MountIndex, warnings: &mut Vec) -> Vec { + let mut infos = mount_index.tmpfs.iter().collect::>(); + let mut seen_devices = BTreeSet::new(); + let mut unique = Vec::new(); + infos.sort_by_key(|info| info.mount_point.as_os_str().len()); + + for info in infos { + match fs::symlink_metadata(&info.mount_point) { + Ok(metadata) if seen_devices.insert(metadata.dev()) => {} + Ok(_) => continue, + Err(error) => { + warnings.push(format!( + "tmpfs scan skipped for {}: {error}", + info.mount_point.display() + )); + continue; + } + } + + unique.push(info.clone()); + } + + unique +} + +fn scan_tmpfs_mount(info: &MountInfo) -> Result { + let root_meta = fs::symlink_metadata(&info.mount_point)?; + let mut seen_storage = BTreeSet::<(u64, u64)>::new(); + let _ = seen_storage.insert((root_meta.dev(), root_meta.ino())); + let mut nodes = BTreeMap::::new(); + let _ = nodes.insert( + info.mount_point.clone(), + TmpfsBuilder { + path: info.mount_point.clone(), + name: info.mount_point.display().to_string(), + kind: TmpfsNodeKind::Mount, + own_allocated: metadata_allocated(&root_meta), + own_logical: metadata_logical(&root_meta), + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }, + ); + + for entry in WalkDir::new(&info.mount_point) + .same_file_system(true) + .follow_links(false) + { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path(); + if path == info.mount_point { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(_) => continue, + }; + + let path_buf = path.to_path_buf(); + let first_storage_name = seen_storage.insert((metadata.dev(), metadata.ino())); + let own_allocated = if first_storage_name { + metadata_allocated(&metadata) + } else { + Bytes::ZERO + }; + let own_logical = if first_storage_name { + metadata_logical(&metadata) + } else { + Bytes::ZERO + }; + let parent = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| info.mount_point.clone()); + nodes + .entry(parent.clone()) + .or_insert_with(|| TmpfsBuilder { + path: parent.clone(), + name: basename(&parent), + kind: TmpfsNodeKind::Directory, + own_allocated: Bytes::ZERO, + own_logical: Bytes::ZERO, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }) + .children + .push(path_buf.clone()); + + let _ = nodes.insert( + path_buf.clone(), + TmpfsBuilder { + path: path_buf, + name: basename(path), + kind: classify_tmpfs_entry(&metadata), + own_allocated, + own_logical, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }, + ); + } + + let mut ordered = nodes.keys().cloned().collect::>(); + ordered.sort_by_key(|path| Reverse(path.components().count())); + for path in ordered { + let Some(node) = nodes.get_mut(&path) else { + continue; + }; + node.allocated += node.own_allocated; + node.logical += node.own_logical; + let allocated = node.allocated; + let logical = node.logical; + let parent = path.parent().map(Path::to_path_buf); + if let Some(parent) = parent.and_then(|parent| nodes.get_mut(&parent)) { + parent.allocated += allocated; + parent.logical += logical; + } + } + + let root = materialize_tmpfs_node(&info.mount_point, &mut nodes); + Ok(TmpfsMount { + mount_point: info.mount_point.clone(), + source: info.source.clone(), + size_limit: parse_tmpfs_size_limit(&info.super_options), + root, + }) +} + +fn materialize_tmpfs_node(path: &Path, nodes: &mut BTreeMap) -> TmpfsNode { + let builder = nodes.remove(path).unwrap_or_else(|| TmpfsBuilder { + path: path.to_path_buf(), + name: basename(path), + kind: TmpfsNodeKind::Other, + own_allocated: Bytes::ZERO, + own_logical: Bytes::ZERO, + allocated: Bytes::ZERO, + logical: Bytes::ZERO, + children: Vec::new(), + }); + + let mut children = builder + .children + .iter() + .map(|child| materialize_tmpfs_node(child, nodes)) + .collect::>(); + children.sort_by(|lhs, rhs| { + rhs.allocated + .cmp(&lhs.allocated) + .then_with(|| lhs.path.cmp(&rhs.path)) + }); + + TmpfsNode { + path: builder.path, + name: builder.name, + kind: builder.kind, + allocated: builder.allocated, + logical: builder.logical, + children, + } +} + +fn basename(path: &Path) -> String { + path.file_name() + .unwrap_or_else(|| OsStr::new("/")) + .to_string_lossy() + .into_owned() +} + +fn metadata_allocated(metadata: &Metadata) -> Bytes { + Bytes::from_blocks_512(metadata.blocks()) +} + +fn metadata_logical(metadata: &Metadata) -> Bytes { + Bytes(metadata.size()) +} + +fn classify_tmpfs_entry(metadata: &Metadata) -> TmpfsNodeKind { + let file_type = metadata.file_type(); + if file_type.is_dir() { + TmpfsNodeKind::Directory + } else if file_type.is_file() { + TmpfsNodeKind::File + } else if file_type.is_symlink() { + TmpfsNodeKind::Symlink + } else if file_type.is_socket() { + TmpfsNodeKind::Socket + } else if file_type.is_fifo() { + TmpfsNodeKind::Fifo + } else if file_type.is_char_device() { + TmpfsNodeKind::CharDevice + } else if file_type.is_block_device() { + TmpfsNodeKind::BlockDevice + } else { + TmpfsNodeKind::Other + } +} + +fn parse_tmpfs_size_limit(options: &str) -> Option { + options + .split(',') + .find_map(|option| option.strip_prefix("size=").and_then(parse_size_option)) +} + +fn parse_size_option(value: &str) -> Option { + let trimmed = value.trim(); + let digits = trimmed + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + let suffix = &trimmed[digits.len()..]; + let number = digits.parse::().ok()?; + let multiplier = match suffix.to_ascii_lowercase().as_str() { + "" => 1, + "k" | "kb" => 1024, + "m" | "mb" => 1024_u64.pow(2), + "g" | "gb" => 1024_u64.pow(3), + "t" | "tb" => 1024_u64.pow(4), + "p" | "pb" => 1024_u64.pow(5), + _ => return None, + }; + Some(Bytes(number.saturating_mul(multiplier))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scanned_process(pid: i32, pss: u64) -> ScannedProcess { + ScannedProcess { + pid: Pid(pid), + ppid: None, + name: format!("p{pid}"), + command: format!("p{pid} --serve"), + username: "test".to_string(), + state: "S".to_string(), + threads: 1, + rollup: MemoryRollup { + pss: Bytes(pss), + rss: Bytes(pss), + ..MemoryRollup::default() + }, + objects: Vec::new(), + rollup_state: LedgerState::Exact, + mappings_state: LedgerState::Deferred, + } + } + + #[test] + fn process_stats_preserve_every_summary_process() { + let processes = vec![ + scanned_process(1, 9_900), + scanned_process(2, 50), + scanned_process(3, 50), + ]; + let stats = process_stats(&processes); + assert_eq!(stats.observed_processes, 3); + assert_eq!(stats.inaccessible_rollups, 0); + assert_eq!(stats.inaccessible_maps, 0); + } + + #[test] + fn parses_mountinfo() { + let line = "839 811 0:34 / /tmp rw,nosuid,nodev master:17 - tmpfs tmpfs rw,size=65909960k"; + let parsed = parse_mountinfo_line(line).expect("mountinfo"); + assert_eq!(parsed.mount_point, PathBuf::from("/tmp")); + assert_eq!(parsed.fs_type, "tmpfs"); + assert_eq!(parsed.source, "tmpfs"); + } + + #[test] + fn parses_mapping_header() { + let line = "7f1230000000-7f1230001000 rw-s 00000000 00:01 42 /memfd:cache shard (deleted)"; + let parsed = parse_mapping_header(line).expect("header"); + assert_eq!(parsed.size, Bytes(0x1000)); + assert!(parsed.path.contains("/memfd:cache shard")); + } + + #[test] + fn parses_size_option_units() { + assert_eq!(parse_size_option("64k"), Some(Bytes(64 * 1024))); + assert_eq!(parse_size_option("2m"), Some(Bytes(2 * 1024 * 1024))); + assert_eq!(parse_size_option("1g"), Some(Bytes(1024 * 1024 * 1024))); + } + + #[test] + fn converts_meminfo_hugepage_counts_to_bytes() { + let parsed = parse_meminfo( + "MemTotal: 1024 kB\nHugePages_Total: 3\nHugePages_Free: 2\nHugepagesize: 2048 kB\n", + ); + assert_eq!(parsed.get("MemTotal"), Bytes(1024 * 1024)); + assert_eq!(parsed.get("HugePages_Total"), Bytes(3 * 2048 * 1024)); + assert_eq!(parsed.get("HugePages_Free"), Bytes(2 * 2048 * 1024)); + } +} diff --git a/crates/memview/src/search.rs b/crates/memview/src/search.rs new file mode 100644 index 0000000..1b31618 --- /dev/null +++ b/crates/memview/src/search.rs @@ -0,0 +1,130 @@ +use crate::model::Bytes; +use regex::Regex; + +#[derive(Clone, Debug)] +pub struct Search { + pattern: String, + regex: Regex, +} + +impl Search { + pub fn compile(pattern: String) -> Result, String> { + if pattern.is_empty() { + return Ok(None); + } + Regex::new(&pattern) + .map(|regex| Some(Self { pattern, regex })) + .map_err(|error| error.to_string()) + } + + #[must_use] + pub fn pattern(&self) -> &str { + &self.pattern + } + + #[must_use] + pub fn matches(&self, value: &str) -> bool { + self.regex.is_match(value) + } +} + +#[derive(Clone, Debug, Default)] +pub struct SearchDraft { + input: String, + error: Option, +} + +impl SearchDraft { + #[must_use] + pub fn new(active: Option<&Search>) -> Self { + Self { + input: active.map_or_else(String::new, |search| search.pattern().to_string()), + error: None, + } + } + + #[must_use] + pub fn from_input(input: String) -> Self { + Self { input, error: None } + } + + #[must_use] + pub fn input(&self) -> &str { + &self.input + } + + #[must_use] + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + pub fn push(&mut self, character: char) { + self.input.push(character); + self.error = None; + } + + pub fn backspace(&mut self) { + let _ = self.input.pop(); + self.error = None; + } + + pub fn clear(&mut self) { + self.input.clear(); + self.error = None; + } + + pub fn fail(&mut self, error: String) { + self.error = Some(error); + } + + #[must_use] + pub fn into_input(self) -> String { + self.input + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum SearchRole { + #[default] + Ordinary, + Match, + Context, +} + +impl SearchRole { + #[must_use] + pub fn is_context(self) -> bool { + self == Self::Context + } +} + +#[derive(Clone, Debug)] +pub struct SearchSummary { + pub matches: usize, + pub total: Bytes, + pub lens: &'static str, +} + +impl SearchSummary { + #[must_use] + pub const fn new(lens: &'static str) -> Self { + Self { + matches: 0, + total: Bytes::ZERO, + lens, + } + } + + pub fn strike(&mut self, value: Bytes) { + self.matches += 1; + self.total += value; + } + + pub fn hit(&mut self) { + self.matches += 1; + } + + pub fn attribute(&mut self, value: Bytes) { + self.total += value; + } +} diff --git a/crates/memview/src/ui.rs b/crates/memview/src/ui.rs new file mode 100644 index 0000000..d170d74 --- /dev/null +++ b/crates/memview/src/ui.rs @@ -0,0 +1,1076 @@ +use crate::app::{App, FlatProcessRow, FlatSharedRow, FlatTmpfsRow, Hotkey, RowFold}; +use crate::model::{Bytes, MeminfoEntry, ObjectUsage, Pid, Snapshot, TmpfsMount}; +use crate::search::SearchRole; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}; +use std::time::Duration; + +const BG: Color = Color::Rgb(12, 17, 24); +const FG: Color = Color::Rgb(221, 227, 234); +const MUTED: Color = Color::Rgb(129, 145, 160); +const ACCENT: Color = Color::Rgb(64, 184, 173); +const HOT: Color = Color::Rgb(227, 116, 94); +const GOLD: Color = Color::Rgb(236, 180, 71); + +pub fn render(frame: &mut Frame<'_>, app: &App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(area); + + frame.render_widget(header(app), chunks[0]); + match app.snapshot.as_ref() { + Some(snapshot) => render_body(frame, app, snapshot, chunks[1]), + None => render_loading(frame, app, chunks[1]), + } + frame.render_widget(footer(app), chunks[2]); + + if app.show_help { + render_help(frame, app, area); + } + if app.kill_confirmation.is_some() { + render_kill_confirmation(frame, app, area); + } + if app.search_draft().is_some() { + render_search_prompt(frame, app, area); + } +} + +fn render_body(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + match app.tab { + crate::app::Tab::Overview => render_overview(frame, app, snapshot, area), + crate::app::Tab::Processes => render_processes(frame, app, snapshot, area), + crate::app::Tab::Tmpfs => render_tmpfs(frame, app, snapshot, area), + crate::app::Tab::Shared => render_shared(frame, app, snapshot, area), + } +} + +fn header(app: &App) -> Paragraph<'static> { + let mut spans = Vec::new(); + spans.push(Span::styled( + " memview ", + Style::default() + .fg(BG) + .bg(ACCENT) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + for (index, label) in App::tab_labels().into_iter().enumerate() { + let style = if app.tab == crate::app::Tab::ALL[index] { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED) + }; + spans.push(Span::styled(label, style)); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!("sort {}", app.metric.label()), + Style::default().fg(GOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("mode {}", app.process_scope.label()), + Style::default().fg(ACCENT), + )); + + Paragraph::new(Line::from(spans)) + .block(panel("Memory Ledger")) + .style(Style::default().fg(FG).bg(BG)) +} + +fn footer(app: &App) -> Paragraph<'static> { + let mut spans = Vec::new(); + if let Some(pattern) = app.search_pattern() { + spans.push(Span::styled( + format!(" FILTER /{pattern}/ "), + Style::default() + .fg(BG) + .bg(GOLD) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "f clear", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!( + "q quit / search ? help {} {}", + pane_footer(app), + app.current_time_label() + ), + Style::default().fg(MUTED), + )); + if let Some(error) = &app.last_error { + spans.push(Span::styled(" last error: ", Style::default().fg(HOT))); + spans.push(Span::styled(error.clone(), Style::default().fg(HOT))); + } + if app.deletion_count() > 0 { + spans.push(Span::styled( + format!(" deleting {} in background", app.deletion_count()), + Style::default().fg(HOT), + )); + } + if let Some(confirmation) = &app.kill_confirmation { + if confirmation.armed() { + spans.push(Span::styled( + " SIGTERM armed: y confirms", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!( + " SIGTERM locked {} ms", + confirmation.lock_remaining().as_millis() + ), + Style::default().fg(GOLD), + )); + } + } + if app.tab.drives_process_scans() + && let Some(started) = app.process_scan_started_at + { + spans.push(Span::styled( + format!(" process scan {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + if let Some((pid, started)) = app.process_mapping_started_at() { + spans.push(Span::styled( + format!(" mappings {pid} {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + if let Some(started) = app.shared_scan_started_at() { + spans.push(Span::styled( + format!(" shared ledger {} ms", started.elapsed().as_millis()), + Style::default().fg(MUTED), + )); + } + Paragraph::new(Line::from(spans)).style(Style::default().bg(BG)) +} + +fn pane_footer(app: &App) -> &'static str { + match app.tab { + crate::app::Tab::Overview => "r refresh overview s lens", + crate::app::Tab::Processes => { + "j/k/Pg/wheel move gg/G edge Enter fold s sort m mode K SIGTERM r rescan" + } + crate::app::Tab::Tmpfs => { + "j/k/Pg/wheel move gg/G edge Enter fold m mode d delete r refresh mount" + } + crate::app::Tab::Shared => "j/k/Pg/wheel move gg/G edge s sort m mode r rescan", + } +} + +fn render_loading(frame: &mut Frame<'_>, app: &App, area: Rect) { + let (title, message) = match app.tab { + crate::app::Tab::Overview => ("Loading", "Reading kernel memory counters..."), + crate::app::Tab::Processes => ("Processes", "Capturing process memory snapshot..."), + crate::app::Tab::Tmpfs => ("Tmpfs", "Scanning tmpfs mounts..."), + crate::app::Tab::Shared => ("Shared", "Reading shared memory ledgers..."), + }; + frame.render_widget( + Paragraph::new(message) + .block(panel(title)) + .style(Style::default().fg(FG)), + area, + ); +} + +fn render_overview(frame: &mut Frame<'_>, _app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) + .split(area); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(11), Constraint::Min(8)]) + .split(columns[1]); + + let mem_rows = snapshot + .meminfo + .entries + .iter() + .map(|entry| row_meminfo(entry, capacity)) + .collect::>(); + frame.render_widget( + Table::new( + mem_rows, + [ + Constraint::Length(18), + Constraint::Length(16), + Constraint::Length(8), + ], + ) + .header(header_row(["Kernel Counter", "Value", "% total"])) + .block(panel("meminfo")) + .column_spacing(1), + columns[0], + ); + + let overview_rows = vec![ + summary_row( + "Σ process PSS", + snapshot.overview.process_pss_total, + capacity, + ), + summary_row( + "Σ process USS", + snapshot.overview.process_uss_total, + capacity, + ), + summary_row( + "Σ process RSS", + snapshot.overview.process_rss_total, + capacity, + ), + summary_row( + "Σ process SwapPSS", + snapshot.overview.process_swap_pss_total, + capacity, + ), + summary_row( + "Σ process PSS anon", + snapshot.overview.process_pss_anon_total, + capacity, + ), + summary_row( + "Σ process PSS file", + snapshot.overview.process_pss_file_total, + capacity, + ), + summary_row( + "Σ process PSS shmem", + snapshot.overview.process_pss_shmem_total, + capacity, + ), + summary_row( + "Σ tmpfs allocated", + snapshot.overview.tmpfs_allocated_total, + capacity, + ), + summary_row("Σ SysV shm RSS", snapshot.overview.sysv_rss_total, capacity), + summary_text_row("processes", &snapshot.overview.process_count.to_string()), + summary_text_row("SysV segments", &snapshot.sysv_segments.len().to_string()), + summary_text_row("scan millis", &snapshot.elapsed.as_millis().to_string()), + summary_row( + "inaccessible rollups", + Bytes(snapshot.overview.inaccessible_rollups as u64), + capacity, + ), + summary_row( + "inaccessible maps", + Bytes(snapshot.overview.inaccessible_maps as u64), + capacity, + ), + ]; + frame.render_widget( + Table::new( + overview_rows, + [Constraint::Length(24), Constraint::Length(16)], + ) + .header(header_row(["Lens", "Value"])) + .block(panel("reconciliation")) + .column_spacing(1), + right[0], + ); + + let warning_lines = if snapshot.warnings.is_empty() { + vec![Line::from(Span::styled( + "No probe warnings. PSS is the attribution lens; tmpfs uses allocated blocks.", + Style::default().fg(FG), + ))] + } else { + snapshot + .warnings + .iter() + .take(24) + .map(|warning| Line::from(Span::styled(warning.clone(), Style::default().fg(HOT)))) + .collect::>() + }; + frame.render_widget( + Paragraph::new(warning_lines) + .block(panel("probe notes")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[1], + ); +} + +fn render_processes(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(57), Constraint::Percentage(43)]) + .split(area); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(12), Constraint::Min(8)]) + .split(columns[1]); + + if snapshot.process_tree.nodes.is_empty() && app.process_scan_started_at.is_some() { + frame.render_widget( + Paragraph::new("Capturing process memory snapshot...") + .block(panel("process tree")) + .style(Style::default().fg(FG)), + columns[0], + ); + frame.render_widget( + Paragraph::new("Per-process PSS, mappings, and object consumers will appear here.") + .block(panel("selected process")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(MUTED)), + right[0], + ); + return; + } + + let rows = app.process_rows(); + let selected = app.selected_process_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let process_rows = visible + .iter() + .enumerate() + .map(|(offset, row)| { + row_process( + app, + snapshot, + row, + visible.start + offset == selected, + capacity, + ) + }) + .collect::>(); + frame.render_widget( + Table::new( + process_rows, + [ + Constraint::Length(30), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Min(24), + ], + ) + .header(header_row([ + "Task", "PID", "User", "PSS", "USS", "RSS", "Command", + ])) + .block(panel(&format!( + "process tree ({})", + app.process_scope.label() + ))) + .column_spacing(1), + columns[0], + ); + + if let Some(process) = app.selected_process() { + let mut details = search_summary_lines(app, capacity); + details.extend([ + Line::from(vec![Span::styled( + process.title(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )]), + detail_line("State", &process.state), + detail_line("Threads", &process.threads.to_string()), + detail_line("PSS", &process.rollup.pss.human_exact()), + detail_line("USS", &process.rollup.uss().human_exact()), + detail_line("RSS", &process.rollup.rss.human_exact()), + detail_line("PSS anon", &process.rollup.pss_anon.human_exact()), + detail_line("PSS file", &process.rollup.pss_file.human_exact()), + detail_line("PSS shmem", &process.rollup.pss_shmem.human_exact()), + detail_line("SwapPSS", &process.rollup.swap_pss.human_exact()), + detail_line( + "Access", + &format!( + "rollup={} maps={}", + process.rollup_state.label(), + app.selected_process_mapping_status() + ), + ), + detail_line("Map scan", &app.selected_process_mapping_scan_label()), + ]); + frame.render_widget( + Paragraph::new(details) + .block(panel("selected process")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } else if app.search_summary().is_some() { + frame.render_widget( + Paragraph::new(search_summary_lines(app, capacity)) + .block(panel("search total")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } + + if let Some((pid, elapsed)) = app.selected_process_mapping_loading() { + frame.render_widget(mapping_loading(pid, elapsed), right[1]); + } else { + let objects = app.selected_process_objects(); + let object_rows = slice_window(objects, 0, right[1].height.saturating_sub(4) as usize) + .iter() + .map(|object| row_object_usage(object, capacity)) + .collect::>(); + frame.render_widget( + Table::new( + object_rows, + [ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Min(24), + ], + ) + .header(header_row(["Kind", "PSS", "RSS", "VMAs", "Object"])) + .block(panel("selected mappings")) + .column_spacing(1), + right[1], + ); + } +} + +fn mapping_loading(pid: Pid, elapsed: Duration) -> Paragraph<'static> { + let dots = ".".repeat(((elapsed.as_millis() / 250) % 4) as usize); + Paragraph::new(vec![ + Line::from(vec![Span::styled( + format!("Loading /proc/{pid}/smaps{dots}"), + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![ + Span::styled("elapsed ", Style::default().fg(MUTED)), + Span::styled( + format!("{} ms", elapsed.as_millis()), + Style::default().fg(FG), + ), + ]), + Line::from(""), + Line::from("The kernel synthesizes per-VMA PSS/RSS here; large mapping tables can stall."), + ]) + .block(panel("selected mappings")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)) +} + +fn render_tmpfs(frame: &mut Frame<'_>, app: &App, _snapshot: &Snapshot, area: Rect) { + let capacity = app + .snapshot + .as_ref() + .map_or(Bytes::ZERO, |snapshot| snapshot.meminfo.get("MemTotal")); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area); + let rows = app.tmpfs_rows(); + let selected = app.selected_tmpfs_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let table_rows = visible + .iter() + .enumerate() + .map(|(offset, row)| row_tmpfs(row, visible.start + offset == selected, capacity)) + .collect::>(); + frame.render_widget( + Table::new( + table_rows, + [ + Constraint::Length(32), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Min(20), + ], + ) + .header(header_row([ + "Entry", + "Kind", + "Allocated", + "Logical", + "Path", + ])) + .block(panel("tmpfs tree")) + .column_spacing(1), + columns[0], + ); + + let mut detail_lines = search_summary_lines(app, capacity); + detail_lines.extend( + match (app.selected_tmpfs_mount(), app.selected_tmpfs_entry()) { + (Some(mount), Some(row)) => tmpfs_detail_lines(mount, row), + _ => vec![Line::from("No tmpfs node selected")], + }, + ); + frame.render_widget( + Paragraph::new(detail_lines) + .block(panel("selected tmpfs entry")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + columns[1], + ); +} + +fn render_shared(frame: &mut Frame<'_>, app: &App, snapshot: &Snapshot, area: Rect) { + let capacity = snapshot.meminfo.get("MemTotal"); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area); + let rows = app.shared_rows(); + let selected = app.selected_shared_row(); + let visible = slice_window(rows, selected, columns[0].height.saturating_sub(4) as usize); + let rows = visible + .iter() + .enumerate() + .map(|(offset, row)| { + row_shared(snapshot, row, visible.start + offset == selected, capacity) + }) + .collect::>(); + frame.render_widget( + Table::new( + rows, + [ + Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Min(24), + ], + ) + .header(header_row([ + "Kind", "Tasks", "PSS", "RSS", "VMAs", "Object", + ])) + .block(panel("global object ledger")) + .column_spacing(1), + columns[0], + ); + + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(8), Constraint::Min(8)]) + .split(columns[1]); + if let Some(object) = app.selected_shared_object() { + let mut summary = search_summary_lines(app, capacity); + summary.extend([ + Line::from(Span::styled( + object.label.clone(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + detail_line("Kind", object.kind.label()), + detail_line("PSS", &object.rollup.pss.human_exact()), + detail_line("RSS", &object.rollup.rss.human_exact()), + detail_line("Swap", &object.rollup.swap.human_exact()), + detail_line("Tasks", &object.mapped_processes.to_string()), + detail_line("VMAs", &object.regions.to_string()), + ]); + frame.render_widget( + Paragraph::new(summary) + .block(panel("selected object")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + + let consumers = slice_window( + &object.consumers, + 0, + right[1].height.saturating_sub(4) as usize, + ) + .iter() + .map(|consumer| { + Row::new(vec![ + Cell::from(consumer.pid.to_string()), + usage_cell(consumer.rollup.pss, capacity), + usage_cell(consumer.rollup.rss, capacity), + Cell::from(consumer.name.clone()), + Cell::from(consumer.command.clone()), + ]) + .style(usage_style(false, consumer.rollup.pss, capacity)) + }) + .collect::>(); + frame.render_widget( + Table::new( + consumers, + [ + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(16), + Constraint::Min(20), + ], + ) + .header(header_row(["PID", "PSS", "RSS", "Name", "Command"])) + .block(panel("top consumers")) + .column_spacing(1), + right[1], + ); + } else if app.search_summary().is_some() { + frame.render_widget( + Paragraph::new(search_summary_lines(app, capacity)) + .block(panel("search total")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + right[0], + ); + } +} + +fn row_meminfo(entry: &MeminfoEntry, total: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(entry.key.clone()), + usage_cell(entry.value, total), + Cell::from(format!("{:.1}", entry.value.pct_of(total))) + .style(Style::default().fg(usage_color(entry.value, total))), + ]) + .style(usage_style(false, entry.value, total)) +} + +fn summary_row(label: &str, value: Bytes, total: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()), + usage_cell(value, total), + ]) + .style(usage_style(false, value, total)) +} + +fn summary_text_row(label: &str, value: &str) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()), + Cell::from(value.to_string()), + ]) +} + +fn row_process( + app: &App, + snapshot: &Snapshot, + row: &FlatProcessRow, + selected: bool, + capacity: Bytes, +) -> Row<'static> { + let node = &snapshot.process_tree.nodes[row.index]; + let rollup = app.process_scope.rollup(node); + let marker = match row.fold { + RowFold::Leaf => " ", + RowFold::Collapsed => "▸", + RowFold::Expanded => "▾", + }; + let name = format!("{}{} {}", " ".repeat(row.depth), marker, node.name); + Row::new(vec![ + Cell::from(name), + Cell::from(node.pid.to_string()), + Cell::from(node.username.clone()), + usage_cell(rollup.pss, capacity), + usage_cell(rollup.uss(), capacity), + usage_cell(rollup.rss, capacity), + Cell::from(node.command.clone()), + ]) + .style(usage_style_for_role( + selected, + rollup.metric(app.metric), + capacity, + row.search, + )) +} + +fn row_tmpfs(row: &FlatTmpfsRow, selected: bool, capacity: Bytes) -> Row<'static> { + let marker = match row.fold { + RowFold::Leaf => " ", + RowFold::Collapsed => "▸", + RowFold::Expanded => "▾", + }; + let label = format!("{}{} {}", " ".repeat(row.depth), marker, row.name); + Row::new(vec![ + Cell::from(label), + Cell::from(row.kind.label().to_string()), + usage_cell(row.allocated, capacity), + usage_cell(row.logical, capacity), + Cell::from(row.path.display().to_string()), + ]) + .style(usage_style_for_role( + selected, + row.allocated, + capacity, + row.search, + )) +} + +fn row_shared( + snapshot: &Snapshot, + row: &FlatSharedRow, + selected: bool, + capacity: Bytes, +) -> Row<'static> { + let object = &snapshot.shared_objects[row.index]; + Row::new(vec![ + Cell::from(object.kind.label().to_string()), + Cell::from(object.mapped_processes.to_string()), + usage_cell(object.rollup.pss, capacity), + usage_cell(object.rollup.rss, capacity), + Cell::from(object.regions.to_string()), + Cell::from(object.label.clone()), + ]) + .style(usage_style_for_role( + selected, + object.rollup.pss, + capacity, + row.search, + )) +} + +fn row_object_usage(object: &ObjectUsage, capacity: Bytes) -> Row<'static> { + Row::new(vec![ + Cell::from(object.kind.label().to_string()), + usage_cell(object.rollup.pss, capacity), + usage_cell(object.rollup.rss, capacity), + Cell::from(object.regions.to_string()), + Cell::from(object.label.clone()), + ]) + .style(usage_style(false, object.rollup.pss, capacity)) +} + +fn tmpfs_detail_lines(mount: &TmpfsMount, row: &FlatTmpfsRow) -> Vec> { + let mut lines = vec![ + Line::from(Span::styled( + row.path.display().to_string(), + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + detail_line("Mount", &mount.mount_point.display().to_string()), + detail_line("Source", &mount.source), + detail_line("Kind", row.kind.label()), + detail_line("Allocated", &row.allocated.human_exact()), + detail_line("Logical", &row.logical.human_exact()), + ]; + if let Some(limit) = mount.size_limit { + lines.push(detail_line("Mount size", &limit.human_exact())); + lines.push(detail_line( + "Utilization", + &format!("{:.1}%", row.allocated.pct_of(limit)), + )); + } + lines +} + +fn search_summary_lines(app: &App, capacity: Bytes) -> Vec> { + let Some(summary) = app.search_summary() else { + return Vec::new(); + }; + let pattern = app.search_pattern().unwrap_or_default(); + vec![ + Line::from(Span::styled( + "regexp matches", + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )), + detail_line("regexp", &format!("/{pattern}/")), + detail_line("matches", &summary.matches.to_string()), + detail_line(summary.lens, &summary.total.human_exact()), + detail_line( + "pct total", + &format!("{:.2}%", summary.total.pct_of(capacity)), + ), + detail_line("mode", app.search_scope_label()), + Line::from(""), + ] +} + +fn detail_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:>12} "), Style::default().fg(MUTED)), + Span::styled(value.to_string(), Style::default().fg(FG)), + ]) +} + +fn header_row(values: [&str; N]) -> Row<'static> { + Row::new( + values + .into_iter() + .map(|value| Cell::from(value.to_string())), + ) + .style(Style::default().fg(GOLD).add_modifier(Modifier::BOLD)) +} + +fn usage_style(selected: bool, value: Bytes, total: Bytes) -> Style { + row_fg_style(selected, usage_color(value, total)) +} + +fn usage_style_for_role(selected: bool, value: Bytes, total: Bytes, role: SearchRole) -> Style { + if role.is_context() && !selected { + Style::default().fg(MUTED) + } else { + usage_style(selected, value, total) + } +} + +fn row_fg_style(selected: bool, fg: Color) -> Style { + if selected { + Style::default() + .fg(fg) + .bg(Color::Rgb(28, 44, 61)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(fg) + } +} + +fn usage_cell(value: Bytes, total: Bytes) -> Cell<'static> { + Cell::from(value.human_iec()).style(Style::default().fg(usage_color(value, total))) +} + +fn usage_color(value: Bytes, total: Bytes) -> Color { + if value.0 == 0 || total.0 == 0 { + return Color::Rgb(105, 113, 121); + } + + let pct = (value.as_f64() / total.as_f64()).clamp(0.0, 1.0); + if pct <= 0.03 { + return blend_rgb((105, 113, 121), (246, 248, 250), pct / 0.03); + } + blend_rgb((246, 248, 250), (232, 58, 46), (pct - 0.03) / 0.97) +} + +fn blend_rgb(start: (u8, u8, u8), end: (u8, u8, u8), t: f64) -> Color { + Color::Rgb( + blend_channel(start.0, end.0, t), + blend_channel(start.1, end.1, t), + blend_channel(start.2, end.2, t), + ) +} + +fn blend_channel(start: u8, end: u8, t: f64) -> u8 { + (f64::from(start) + (f64::from(end) - f64::from(start)) * t) + .round() + .clamp(0.0, 255.0) as u8 +} + +fn panel(title: &str) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title.to_string()) + .style(Style::default().fg(FG).bg(BG)) +} + +fn render_help(frame: &mut Frame<'_>, app: &App, area: Rect) { + let popup = centered_rect(area, 82, 88); + frame.render_widget(Clear, popup); + let hotkeys = app.hotkey_sections(); + let mut text = vec![ + Line::from(Span::styled( + "memview keys", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + section_heading("Global"), + ]; + text.extend(hotkeys.global.iter().map(hotkey_line)); + text.extend([ + Line::from(""), + section_heading(&format!("Pane: {}", hotkeys.pane_title)), + ]); + text.extend(hotkeys.pane.iter().map(hotkey_line)); + text.extend([ + Line::from(""), + section_heading("Notes"), + Line::from("Overview shows raw kernel counters and the main reconciliation lenses."), + Line::from("Processes uses PSS so shared pages are not double-counted."), + Line::from( + "Tmpfs uses allocated blocks, which is closer to actual backing than file length.", + ), + Line::from("Tree panes auto-fold subtrees below min(1% RAM, 3% largest non-root subtree)."), + Line::from( + "Shared aggregates mapped objects across tasks: tmpfs, memfd, SYSV, files, and anon.", + ), + ]); + frame.render_widget( + Paragraph::new(text) + .block(panel("Help")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + popup, + ); +} + +fn section_heading(label: &str) -> Line<'static> { + Line::from(Span::styled( + label.to_string(), + Style::default().fg(GOLD).add_modifier(Modifier::BOLD), + )) +} + +fn hotkey_line(hotkey: &Hotkey) -> Line<'static> { + detail_line(hotkey.key, hotkey.action) +} + +fn render_kill_confirmation(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(confirmation) = app.kill_confirmation.as_ref() else { + return; + }; + + let popup = centered_rect(area, 88, 88); + frame.render_widget(Clear, popup); + let block = panel("kill"); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(inner); + + let top = vec![ + Line::from(Span::styled( + "Send SIGTERM to this process?", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + detail_line("PID", &confirmation.target.pid.to_string()), + detail_line("Name", &confirmation.target.name), + ]; + frame.render_widget( + Paragraph::new(top).style(Style::default().fg(FG)), + sections[0], + ); + + frame.render_widget( + Paragraph::new(confirmation.target.cli().to_string()) + .block( + Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .title("Full CLI") + .style(Style::default().fg(ACCENT).bg(BG)), + ) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG).bg(BG)), + sections[1], + ); + + let mut controls = Vec::new(); + if confirmation.armed() { + controls.push(Line::from(Span::styled( + "press y to send SIGTERM", + Style::default().fg(HOT).add_modifier(Modifier::BOLD), + ))); + } else { + controls.push(detail_line( + "lockout", + &format!( + "{} ms before y is accepted", + confirmation.lock_remaining().as_millis() + ), + )); + } + controls.push(detail_line("cancel", "Esc or n")); + + frame.render_widget( + Paragraph::new(controls) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG).bg(BG)), + sections[2], + ); +} + +fn render_search_prompt(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(draft) = app.search_draft() else { + return; + }; + + let popup = centered_rect(area, 76, 22); + frame.render_widget(Clear, popup); + let mut lines = vec![ + Line::from(Span::styled( + "Filter rows by regexp", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("/", Style::default().fg(GOLD)), + Span::styled(draft.input().to_string(), Style::default().fg(FG)), + ]), + Line::from(""), + detail_line("accept", "Enter"), + detail_line("clear", "empty input, then Enter"), + detail_line("cancel", "Esc"), + ]; + if let Some(error) = draft.error() { + lines.push(detail_line("error", error)); + } + frame.render_widget( + Paragraph::new(lines) + .block(panel("search")) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(FG)), + popup, + ); +} + +fn centered_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_pct) / 2), + Constraint::Percentage(height_pct), + Constraint::Percentage((100 - height_pct) / 2), + ]) + .split(area); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_pct) / 2), + Constraint::Percentage(width_pct), + Constraint::Percentage((100 - width_pct) / 2), + ]) + .split(vertical[1]); + horizontal[1] +} + +struct SliceWindow<'a, T> { + start: usize, + slice: &'a [T], +} + +impl std::ops::Deref for SliceWindow<'_, T> { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.slice + } +} + +fn slice_window(items: &[T], selected: usize, height: usize) -> SliceWindow<'_, T> { + if items.is_empty() { + return SliceWindow { + start: 0, + slice: items, + }; + } + let height = height.max(1); + let half = height / 2; + let start = selected + .saturating_sub(half) + .min(items.len().saturating_sub(height)); + let end = (start + height).min(items.len()); + SliceWindow { + start, + slice: &items[start..end], + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8770b1d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" +components = ["clippy", "rustfmt"] -- cgit v1.2.3