Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package ttl for openSUSE:Factory checked in at 2026-09-04 12:38:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/ttl (Old) and /work/SRC/openSUSE:Factory/.ttl.new.1265 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "ttl" Fri Sep 4 12:38:40 2026 rev:12 rq:1375443 version:0.22.0 Changes: -------- --- /work/SRC/openSUSE:Factory/ttl/ttl.changes 2026-08-18 17:17:03.775614983 +0200 +++ /work/SRC/openSUSE:Factory/.ttl.new.1265/ttl.changes 2026-09-04 12:39:12.251043271 +0200 @@ -1,0 +2,9 @@ +Tue Sep 1 12:37:04 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 0.22.0: + * chore(tui): make the render fingerprint exhaustive over its + inputs. + * perf(tui): skip snapshot+draw when idle (LAN-1222 experiment). + * chore: exclude demo assets (gif/tape) from published crate. + +------------------------------------------------------------------- Old: ---- ttl-0.21.0.obscpio New: ---- ttl-0.22.0.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ ttl.spec ++++++ --- /var/tmp/diff_new_pack.c14MWX/_old 2026-09-04 12:39:16.333186671 +0200 +++ /var/tmp/diff_new_pack.c14MWX/_new 2026-09-04 12:39:16.336186777 +0200 @@ -17,7 +17,7 @@ Name: ttl -Version: 0.21.0 +Version: 0.22.0 Release: 0 Summary: Modern traceroute/mtr-style TUI License: Apache-2.0 OR MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.c14MWX/_old 2026-09-04 12:39:16.419189692 +0200 +++ /var/tmp/diff_new_pack.c14MWX/_new 2026-09-04 12:39:16.430190079 +0200 @@ -2,7 +2,7 @@ <service name="obs_scm" mode="manual"> <param name="url">https://github.com/lance0/ttl</param> <param name="scm">git</param> - <param name="revision">v0.21.0</param> + <param name="revision">v0.22.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.c14MWX/_old 2026-09-04 12:39:16.491192222 +0200 +++ /var/tmp/diff_new_pack.c14MWX/_new 2026-09-04 12:39:16.501192573 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/lance0/ttl</param> - <param name="changesrevision">46f1dc2cd8c055408a764d5674afa897ca7cd30c</param></service></servicedata> + <param name="changesrevision">987b926707b3ae64de54793c49c7bae104498402</param></service></servicedata> (No newline at EOF) ++++++ ttl-0.21.0.obscpio -> ttl-0.22.0.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/CHANGELOG.md new/ttl-0.22.0/CHANGELOG.md --- old/ttl-0.21.0/CHANGELOG.md 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/CHANGELOG.md 2026-08-24 18:31:53.000000000 +0200 @@ -5,6 +5,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.22.0] - 2026-08-24 + +### Changed +- **The TUI redraws only when something changed.** The interactive view previously rebuilt its session snapshot and repainted the terminal on every 16 ms tick (~60 fps) whether or not a probe had landed — at the default 1 s probe interval roughly 55 of every 60 frames were pure churn. Each tick now compares a fingerprint of everything the view renders (hop-stat generation, overlays, theme, status line, replay clock, settings, target list, IX cache state) against the previous frame and skips both the snapshot clone and the draw when they match. Measured on a 9-hop trace at the default interval, **1.4% → 0.4% of one CPU core**, and the saving grows with hop count. Output is unchanged and the tick rate is still 16 ms, so input, replay animation, and terminal resizes stay as responsive as before. +- **The published crate no longer bundles the demo assets.** `docs/*.gif` and `docs/*.tape` are excluded from the package, roughly halving the crates.io tarball (1.14 MB → 0.56 MB compressed) for every `cargo install ttl`. The README still renders the demo from the repository. + +### Security +- **`rkyv` 0.8.16 → 0.8.18** clears RUSTSEC-2026-0233 (use-after-free during deserialization) and RUSTSEC-2026-0234 / RUSTSEC-2026-0235 (out-of-bounds reads on crafted archives). The advisory reaches ttl transitively via `getifs` → `smallvec-wrapper`. +- **`lru` 0.18.0 → 0.18.2** clears RUSTSEC-2026-0253 (potential use-after-free from missing panic safety in `LruCache::pop()`). The advisory reaches ttl transitively via `ratatui` → `ratatui-core`. + +### Dependencies +- maxminddb 0.29 → 0.30 (direct), plus tokio 1.52.3 → 1.53.1, clap 4.6.1 → 4.6.6, clap_complete 4.6.7 → 4.6.9, futures 0.3.32 → 0.3.34, libc 0.2.186 → 0.2.189, serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, socket2 0.6.4 → 0.6.5, and anyhow 1.0.103 → 1.0.104. + ## [0.21.0] - 2026-07-07 ### Added diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/Cargo.lock new/ttl-0.22.0/Cargo.lock --- old/ttl-0.21.0/Cargo.lock 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/Cargo.lock 2026-08-24 18:31:53.000000000 +0200 @@ -84,9 +84,9 @@ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -325,9 +325,9 @@ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -335,9 +335,9 @@ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -347,23 +347,23 @@ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -872,9 +872,9 @@ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -887,9 +887,9 @@ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -897,15 +897,15 @@ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -914,38 +914,38 @@ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1648,9 +1648,9 @@ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -1711,9 +1711,9 @@ [[package]] name = "lru" -version = "0.18.0" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ "hashbrown 0.17.0", ] @@ -1736,12 +1736,11 @@ [[package]] name = "maxminddb" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e84ef32bcbf18a95548989e880db4af6fafd563463753afb4b9a149fb2782c" +checksum = "a7bd67e2f5d65937ff283e418fcad53c8e2eeda0719e455508b62dad8ffe6180" dependencies = [ "ipnetwork 0.21.1", - "log", "memchr", "serde", "thiserror 2.0.18", @@ -2710,9 +2709,9 @@ [[package]] name = "rkyv" -version = "0.8.16" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ "bytecheck", "bytes", @@ -2730,13 +2729,13 @@ [[package]] name = "rkyv_derive" -version = "0.8.16" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2923,9 +2922,9 @@ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2933,29 +2932,29 @@ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3098,9 +3097,9 @@ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3174,6 +3173,17 @@ ] [[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3409,9 +3419,9 @@ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3469,9 +3479,9 @@ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -3482,9 +3492,9 @@ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -3506,18 +3516,18 @@ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -3601,7 +3611,7 @@ [[package]] name = "ttl" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "chrono", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/Cargo.toml new/ttl-0.22.0/Cargo.toml --- old/ttl-0.21.0/Cargo.toml 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/Cargo.toml 2026-08-24 18:31:53.000000000 +0200 @@ -1,6 +1,6 @@ [package] name = "ttl" -version = "0.21.0" +version = "0.22.0" edition = "2024" rust-version = "1.88" description = "Modern traceroute/mtr-style TUI with hop stats and optional ASN/geo enrichment" @@ -8,6 +8,9 @@ repository = "https://github.com/lance0/ttl" keywords = ["traceroute", "mtr", "network", "tui", "diagnostics"] categories = ["command-line-utilities", "network-programming"] +# Keep the published crate lean: the README demo assets live in the git repo +# only (crates.io renders README images from the repo, not the tarball). +exclude = ["docs/*.gif", "docs/*.tape"] [features] default = ["update-check"] @@ -42,7 +45,7 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } # ASN/Geo databases -maxminddb = "0.29" +maxminddb = "0.30" ipnetwork = "0.21" # Serialization diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/ROADMAP.md new/ttl-0.22.0/ROADMAP.md --- old/ttl-0.21.0/ROADMAP.md 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/ROADMAP.md 2026-08-24 18:31:53.000000000 +0200 @@ -105,6 +105,14 @@ --- +## Completed (v0.22.0) + +- [x] TUI renders only on change (#129, #131): a per-tick render fingerprint skips the session snapshot and the redraw when nothing moved — 1.4% → 0.4% of one CPU core on a 9-hop trace, scaling with hop count. Session mutations mark the view dirty through the lock's write guard, so no writer can leave the display stale. +- [x] Published crate excludes the demo GIF/tape, halving the crates.io tarball (1.14 MB → 0.56 MB compressed) +- [x] Security: rkyv 0.8.18 (RUSTSEC-2026-0233/0234/0235) and lru 0.18.2 (RUSTSEC-2026-0253) + +--- + ## Planned Features ### Before next release — TTL send-path correctness (follow-ups to #12) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/main.rs new/ttl-0.22.0/src/main.rs --- old/ttl-0.21.0/src/main.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/main.rs 2026-08-24 18:31:53.000000000 +0200 @@ -36,7 +36,7 @@ InterfaceInfo, check_permissions, create_send_socket_with_interface, detect_default_gateway, get_local_addr_with_interface, validate_interface, }; -use state::{Session, Target, run_ratelimit_worker}; +use state::{Session, SessionLock, Target, run_ratelimit_worker}; use trace::engine::ProbeEngine; use trace::pending::{PendingMap, new_pending_map}; use trace::receiver::{ReceiverConfig, SessionMap, spawn_receiver}; @@ -112,7 +112,7 @@ // Resolve all targets let mut targets: Vec<IpAddr> = Vec::new(); - let mut sessions_map: HashMap<IpAddr, Arc<RwLock<Session>>> = HashMap::new(); + let mut sessions_map: HashMap<IpAddr, Arc<SessionLock>> = HashMap::new(); let config = Config::from(&args); let mut effective_flows_v4 = None; let mut effective_flows_v6 = None; @@ -157,7 +157,7 @@ detect_default_gateway(ipv6) }; - sessions_map.insert(resolved_ip, Arc::new(RwLock::new(session))); + sessions_map.insert(resolved_ip, Arc::new(SessionLock::new(session))); targets.push(resolved_ip); } @@ -212,7 +212,7 @@ detect_default_gateway(ipv6) }; - sessions_map.insert(resolved_ip, Arc::new(RwLock::new(session))); + sessions_map.insert(resolved_ip, Arc::new(SessionLock::new(session))); targets.push(resolved_ip); } @@ -506,11 +506,11 @@ }; // Show in TUI - let state = Arc::new(RwLock::new(session_to_display)); + let state = Arc::new(SessionLock::new(session_to_display)); let cancel = CancellationToken::new(); // Create SessionMap with single session - let mut sessions_map: HashMap<IpAddr, Arc<RwLock<Session>>> = HashMap::new(); + let mut sessions_map: HashMap<IpAddr, Arc<SessionLock>> = HashMap::new(); sessions_map.insert(target_ip, state); let sessions: SessionMap = Arc::new(RwLock::new(sessions_map)); let targets = vec![target_ip]; @@ -1126,7 +1126,7 @@ detect_default_gateway(ipv6) }; - let state = Arc::new(RwLock::new(session)); + let state = Arc::new(SessionLock::new(session)); ctx.sessions.write().insert(ip, state.clone()); // Spawn a receiver if this IP family doesn't have one yet @@ -1674,7 +1674,7 @@ if session.events.is_empty() { continue; } - session.events.drain(..).collect() + std::mem::take(&mut session.events) }; for event in &events { write_event_line(&mut out, *target_ip, event)?; @@ -1822,7 +1822,7 @@ } fn make_session_map(entries: &[(IpAddr, u8)]) -> SessionMap { - let mut map: HashMap<IpAddr, Arc<RwLock<Session>>> = HashMap::new(); + let mut map: HashMap<IpAddr, Arc<SessionLock>> = HashMap::new(); for (target_ip, flows) in entries { let mut target = Target::new(target_ip.to_string(), *target_ip); target.hostname = Some(format!("host-{}", target_ip)); @@ -1832,7 +1832,7 @@ }; map.insert( *target_ip, - Arc::new(RwLock::new(Session::new(target, config))), + Arc::new(SessionLock::new(Session::new(target, config))), ); } Arc::new(RwLock::new(map)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/metrics.rs new/ttl-0.22.0/src/metrics.rs --- old/ttl-0.21.0/src/metrics.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/metrics.rs 2026-08-24 18:31:53.000000000 +0200 @@ -268,7 +268,7 @@ mod tests { use super::*; use crate::config::Config; - use crate::state::{Session, Target}; + use crate::state::{Session, SessionLock, Target}; use parking_lot::RwLock; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr}; @@ -292,7 +292,7 @@ session.total_sent = 2; let mut map = HashMap::new(); - map.insert(target_ip, Arc::new(RwLock::new(session))); + map.insert(target_ip, Arc::new(SessionLock::new(session))); Arc::new(RwLock::new(map)) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/probe/correlate.rs new/ttl-0.22.0/src/probe/correlate.rs --- old/ttl-0.21.0/src/probe/correlate.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/probe/correlate.rs 2026-08-24 18:31:53.000000000 +0200 @@ -103,9 +103,8 @@ let mut labels = Vec::new(); // Each label entry is 4 bytes - for chunk in label_data.chunks_exact(4) { - // chunks_exact(4) guarantees chunk.len() == 4, so this is infallible - let bytes: [u8; 4] = [chunk[0], chunk[1], chunk[2], chunk[3]]; + for chunk in label_data.as_chunks::<4>().0 { + let bytes: [u8; 4] = *chunk; let label = MplsLabel::from_bytes(&bytes); labels.push(label); // Stop at bottom of stack diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/state/session.rs new/ttl-0.22.0/src/state/session.rs --- old/ttl-0.21.0/src/state/session.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/state/session.rs 2026-08-24 18:31:53.000000000 +0200 @@ -2,8 +2,11 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; use std::net::IpAddr; +use std::ops::{Deref, DerefMut}; use std::time::Duration; +use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; + use crate::config::Config; /// Window size for recent RTT/result tracking (used for sparklines, jitter calculation) @@ -1331,6 +1334,51 @@ /// Recorded probe events for animated replay #[serde(default, skip_serializing_if = "Vec::is_empty")] pub events: Vec<ProbeEvent>, + /// Bumped every time a [`SessionLock`] write guard is released, so the TUI + /// can skip snapshot+draw on ticks where nothing changed. Not serialized. + #[serde(skip)] + pub render_generation: u64, +} + +/// `RwLock<Session>` whose write guard bumps [`Session::render_generation`] +/// on release, so every mutation path — hop stats, enrichment, PMTUD, pause — +/// marks the TUI dirty without each writer having to remember to. +pub struct SessionLock(RwLock<Session>); + +impl SessionLock { + pub fn new(session: Session) -> Self { + Self(RwLock::new(session)) + } + + pub fn read(&self) -> RwLockReadGuard<'_, Session> { + self.0.read() + } + + pub fn write(&self) -> SessionWriteGuard<'_> { + SessionWriteGuard(self.0.write()) + } +} + +/// Write guard for [`SessionLock`]; bumps `render_generation` when dropped. +pub struct SessionWriteGuard<'a>(RwLockWriteGuard<'a, Session>); + +impl Deref for SessionWriteGuard<'_> { + type Target = Session; + fn deref(&self) -> &Session { + &self.0 + } +} + +impl DerefMut for SessionWriteGuard<'_> { + fn deref_mut(&mut self) -> &mut Session { + &mut self.0 + } +} + +impl Drop for SessionWriteGuard<'_> { + fn drop(&mut self) { + self.0.render_generation = self.0.render_generation.wrapping_add(1); + } } impl Session { @@ -1362,6 +1410,7 @@ source_ip: None, gateway: None, events: Vec::new(), + render_generation: 0, } } @@ -1507,6 +1556,7 @@ source_ip: self.source_ip, gateway: self.gateway, events: Vec::new(), + render_generation: self.render_generation, } } @@ -2607,6 +2657,28 @@ } #[test] + fn test_session_lock_write_bumps_render_generation() { + let target = Target::new( + "test.com".to_string(), + IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4)), + ); + let lock = SessionLock::new(Session::new(target, Config::default())); + assert_eq!(lock.read().render_generation, 0); + + // Any write-guard release bumps, regardless of which field changed. + lock.write().total_sent += 1; + assert_eq!(lock.read().render_generation, 1); + lock.write().reset_stats(); + assert_eq!(lock.read().render_generation, 2); + + // Reads never bump. + let snap = lock.read().snapshot_for_render(); + assert_eq!(lock.read().render_generation, 2); + assert_eq!(snap.render_generation, 2); + assert!(snap.events.is_empty()); + } + + #[test] fn test_target_new() { let ip = IpAddr::V4(std::net::Ipv4Addr::new(8, 8, 8, 8)); let target = Target::new("google.com".to_string(), ip); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/trace/engine.rs new/ttl-0.22.0/src/trace/engine.rs --- old/ttl-0.21.0/src/trace/engine.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/trace/engine.rs 2026-08-24 18:31:53.000000000 +0200 @@ -1,5 +1,4 @@ use anyhow::Result; -use parking_lot::RwLock; use socket2::Socket; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; @@ -24,7 +23,7 @@ use crate::probe::{enable_recv_ttl, parse_icmp_response, recv_icmp_with_ttl}; #[cfg(target_os = "linux")] use crate::state::IcmpResponseType; -use crate::state::{PmtudPhase, ProbeId, Session}; +use crate::state::{PmtudPhase, ProbeId, SessionLock}; use crate::trace::pending::{PendingMap, PendingProbe}; /// Safety cap for IPv6 echo-reply draining per tick. @@ -37,7 +36,7 @@ config: Config, target: IpAddr, identifier: u16, - state: Arc<RwLock<Session>>, + state: Arc<SessionLock>, pending: PendingMap, cancel: CancellationToken, interface: Option<InterfaceInfo>, @@ -47,7 +46,7 @@ pub fn new( config: Config, target: IpAddr, - state: Arc<RwLock<Session>>, + state: Arc<SessionLock>, pending: PendingMap, cancel: CancellationToken, interface: Option<InterfaceInfo>, @@ -1509,6 +1508,7 @@ #[cfg(test)] mod tests { use super::*; + use crate::state::Session; use crate::state::Target; use crate::trace::pending::new_pending_map; use std::net::{IpAddr, Ipv4Addr}; @@ -1519,7 +1519,7 @@ ..Config::default() }; let target = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); - let session = Arc::new(RwLock::new(Session::new( + let session = Arc::new(SessionLock::new(Session::new( Target::new("test".to_string(), target), config.clone(), ))); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/trace/receiver.rs new/ttl-0.22.0/src/trace/receiver.rs --- old/ttl-0.21.0/src/trace/receiver.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/trace/receiver.rs 2026-08-24 18:31:53.000000000 +0200 @@ -11,12 +11,12 @@ recv_icmp_with_ttl, }; use crate::state::{ - IcmpResponseType, MplsLabel, PmtudPhase, ProbeEvent, ProbeId, ProbeOutcome, Session, + IcmpResponseType, MplsLabel, PmtudPhase, ProbeEvent, ProbeId, ProbeOutcome, SessionLock, }; use crate::trace::pending::{PendingKey, PendingMap, PendingProbe}; /// Map of target IP to session, shared across multiple engines and the receiver -pub type SessionMap = Arc<RwLock<HashMap<IpAddr, Arc<RwLock<Session>>>>>; +pub type SessionMap = Arc<RwLock<HashMap<IpAddr, Arc<SessionLock>>>>; /// Configuration for the ICMP receiver #[derive(Clone)] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ttl-0.21.0/src/tui/app.rs new/ttl-0.22.0/src/tui/app.rs --- old/ttl-0.21.0/src/tui/app.rs 2026-07-07 16:42:22.000000000 +0200 +++ new/ttl-0.22.0/src/tui/app.rs 2026-08-24 18:31:53.000000000 +0200 @@ -128,6 +128,186 @@ } } +/// Post-poll render fingerprint (LAN-1222). Built after status expiry, +/// add-target, update check, IX cache status, and target-list refresh so +/// idle skip cannot leave those chrome surfaces stale. +#[derive(Clone, Debug, PartialEq, Eq)] +struct RenderFingerprint { + session_generation: Option<u64>, + selected: Option<usize>, + paused: bool, + show_help: bool, + show_hop_detail: bool, + show_settings: bool, + show_target_list: bool, + show_target_input: bool, + theme_index: usize, + display_mode: crate::prefs::DisplayMode, + selected_target: usize, + num_targets: usize, + target_list_index: usize, + status: Option<String>, + update_available: Option<String>, + replay: Option<(bool, bool, usize, u64, u32)>, + target_input: (String, usize, Option<String>, bool), + settings: ( + usize, + usize, + usize, + crate::prefs::DisplayMode, + String, + usize, + bool, + ), + ix_cache: Option<(bool, usize, Option<u64>, bool, bool)>, + target_list_rows: Option<Vec<(IpAddr, String, String, String)>>, +} + +fn render_fingerprint( + ui_state: &UiState, + num_targets: usize, + session_generation: Option<u64>, + cache_status: Option<&crate::lookup::ix::CacheStatus>, +) -> RenderFingerprint { + // Exhaustive destructures, deliberately without `..`: adding a field to + // any of these structs fails to compile here until it is either + // fingerprinted or explicitly bound to `_`, so new render-relevant state + // cannot silently render late. + let UiState { + selected, + paused, + show_help, + show_hop_detail, + show_settings, + settings, + status_message, + theme_index, + display_mode, + selected_target, + show_target_list, + target_list_index, + update_available, + update_rx: _, + replay_state, + target_list_cache, + target_list_tick: _, + show_target_input, + target_input, + } = ui_state; + let SettingsState { + selected_section, + theme_scroll, + theme_index: settings_theme_index, + display_mode: settings_display_mode, + api_key, + api_key_cursor, + update_check, + } = settings; + let TargetInputState { + input, + cursor, + error, + resolving, + pending: _, + } = target_input; + let replay = replay_state.as_ref().map(|r| { + let ReplayState { + events: _, // fixed after load + current_index, + replay_started_at: _, // both clock fields feed replay_current_ms(r) + speed_multiplier, + paused: replay_paused, + finished, + paused_at_elapsed_ms: _, + } = r; + ( + *replay_paused, + *finished, + *current_index, + replay_current_ms(r), + speed_multiplier.to_bits(), + ) + }); + RenderFingerprint { + session_generation, + selected: *selected, + paused: *paused, + show_help: *show_help, + show_hop_detail: *show_hop_detail, + show_settings: *show_settings, + show_target_list: *show_target_list, + show_target_input: *show_target_input, + theme_index: *theme_index, + display_mode: *display_mode, + selected_target: *selected_target, + num_targets, + target_list_index: *target_list_index, + status: status_message.as_ref().map(|(m, _)| m.clone()), + update_available: update_available.clone(), + replay, + target_input: (input.clone(), *cursor, error.clone(), *resolving), + settings: ( + *selected_section, + *theme_scroll, + *settings_theme_index, + *settings_display_mode, + api_key.clone(), + *api_key_cursor, + *update_check, + ), + ix_cache: cache_status.map(|c| { + let crate::lookup::ix::CacheStatus { + loaded, + prefix_count, + fetched_at, + expired, + refreshing, + } = c; + (*loaded, *prefix_count, *fetched_at, *expired, *refreshing) + }), + target_list_rows: target_list_cache.as_ref().map(|rows| { + rows.iter() + .map(|t| { + let TargetInfo { + ip, + hostname, + hops_str, + loss_str, + } = t; + (*ip, hostname.clone(), hops_str.clone(), loss_str.clone()) + }) + .collect() + }), + } +} + +fn poll_update_check(ui_state: &mut UiState) { + if let (None, Some(rx)) = (&ui_state.update_available, &ui_state.update_rx) { + match rx.try_recv() { + Ok(result) => { + ui_state.update_available = result; + ui_state.update_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + ui_state.update_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + } +} + +fn refresh_target_list_cache(ui_state: &mut UiState, sessions: &SessionMap, targets: &[IpAddr]) { + if ui_state.show_target_list { + ui_state.target_list_tick += 1; + if ui_state.target_list_cache.is_none() || ui_state.target_list_tick.is_multiple_of(30) { + ui_state.target_list_cache = Some(extract_target_infos(sessions, targets)); + } + } else { + ui_state.target_list_cache = None; + ui_state.target_list_tick = 0; + } +} + /// Run the TUI application. Returns the final preferences for persistence. #[allow(clippy::too_many_arguments)] pub async fn run_tui( @@ -527,6 +707,7 @@ { let theme_names = Theme::list(); let event_rx = spawn_event_reader(tick_rate, cancel.clone()); + let mut last_draw: Option<RenderFingerprint> = None; let ix_enabled = ix_lookup.is_some(); loop { @@ -545,20 +726,7 @@ // Compute count AFTER polling so newly added targets are reflected this tick. let num_targets = targets.len(); - // Poll for update check result (non-blocking) - // Drop receiver once we get any result (Some or None) or sender disconnects - if let (None, Some(rx)) = (&ui_state.update_available, &ui_state.update_rx) { - match rx.try_recv() { - Ok(result) => { - ui_state.update_available = result; - ui_state.update_rx = None; - } - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - ui_state.update_rx = None; - } - Err(std::sync::mpsc::TryRecvError::Empty) => {} - } - } + poll_update_check(ui_state); // Process replay animation tick if let Some(ref mut replay) = ui_state.replay_state @@ -613,50 +781,61 @@ // Get cache status for settings modal let cache_status = ix_lookup.as_ref().map(|ix| ix.get_cache_status()); - // Snapshot session data BEFORE draw so no locks are held during rendering. - // Uses snapshot_for_render() to skip cloning the events vec (unbounded, not used in render). - let session_snapshot = { + // Snapshot only when dirty so no locks are held during rendering. + refresh_target_list_cache(ui_state, &sessions, &targets); + + // Fingerprint after polls so status expiry, add-target, update + // check, IX cache, and target-list rows participate (LAN-1222). + let session_generation = { let sessions_read = sessions.read(); sessions_read .get(¤t_target) - .map(|state| state.read().snapshot_for_render()) + .map(|state| state.read().render_generation) }; + let fingerprint = render_fingerprint( + ui_state, + num_targets, + session_generation, + cache_status.as_ref(), + ); + let dirty = last_draw.as_ref() != Some(&fingerprint); - // Refresh target list cache (~every 500ms while overlay is open) - if ui_state.show_target_list { - ui_state.target_list_tick += 1; - if ui_state.target_list_cache.is_none() || ui_state.target_list_tick.is_multiple_of(30) - { - ui_state.target_list_cache = Some(extract_target_infos(&sessions, &targets)); + if dirty { + let session_snapshot = { + let sessions_read = sessions.read(); + sessions_read + .get(¤t_target) + .map(|state| state.read().snapshot_for_render()) + }; + + if let Some(ref session) = session_snapshot { + terminal.draw(|f| { + draw_ui( + f, + session, + ui_state, + &theme, + num_targets, + cache_status.clone(), + ix_enabled, + ); + })?; + } else { + terminal.draw(|f| { + draw_empty_state(f, ui_state, &theme, cache_status.clone(), ix_enabled); + })?; } - } else { - ui_state.target_list_cache = None; - ui_state.target_list_tick = 0; + last_draw = Some(fingerprint); } - - // Draw (no locks held — all data is pre-extracted snapshots) - if let Some(ref session) = session_snapshot { - terminal.draw(|f| { - draw_ui( - f, - session, - ui_state, - &theme, - num_targets, - cache_status.clone(), - ix_enabled, - ); - })?; - } else { - // No targets yet (interactive empty mode) - terminal.draw(|f| { - draw_empty_state(f, ui_state, &theme, cache_status.clone(), ix_enabled); - })?; - } - // Handle input — non-blocking: the event-reader thread forwards // crossterm events over the channel so we never stall the async runtime. - if let Ok(Event::Key(key)) = event_rx.try_recv() { + let event = event_rx.try_recv().ok(); + // A resize changes the layout without touching any fingerprinted state, + // so force the next tick to draw (terminal.draw re-measures the area). + if matches!(event, Some(Event::Resize(..))) { + last_draw = None; + } + if let Some(Event::Key(key)) = event { if key.kind != KeyEventKind::Press { continue; } @@ -1454,6 +1633,7 @@ mod tests { use super::*; use crate::config::Config; + use crate::state::SessionLock; use crate::state::Target; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr}; @@ -1461,7 +1641,7 @@ fn make_single_session_map(target: IpAddr, session: Session) -> SessionMap { let mut map = HashMap::new(); - map.insert(target, Arc::new(parking_lot::RwLock::new(session))); + map.insert(target, Arc::new(SessionLock::new(session))); Arc::new(parking_lot::RwLock::new(map)) } @@ -1585,4 +1765,135 @@ assert_eq!(replay.current_index, 0); assert!(replay.paused); } + + #[test] + fn test_fingerprint_changes_when_status_expires() { + let mut ui_state = UiState { + status_message: Some(( + "Working".to_string(), + std::time::Instant::now() - Duration::from_secs(4), + )), + ..UiState::default() + }; + let before = render_fingerprint(&ui_state, 1, Some(0), None); + ui_state.clear_old_status(); + let after = render_fingerprint(&ui_state, 1, Some(0), None); + assert!(ui_state.status_message.is_none()); + assert_ne!(before, after); + } + + #[test] + fn test_fingerprint_changes_on_add_target_poll() { + let existing = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let added = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)); + let mut targets = vec![existing]; + let mut ui_state = UiState { + target_input: TargetInputState { + resolving: true, + ..TargetInputState::default() + }, + show_target_input: true, + ..UiState::default() + }; + let (tx, rx) = tokio::sync::oneshot::channel(); + ui_state.target_input.pending = Some(rx); + let before = render_fingerprint(&ui_state, targets.len(), Some(0), None); + tx.send(Ok(crate::tui::views::AddedTarget { + ip: added, + name: "dns.google".to_string(), + existed: false, + })) + .unwrap(); + poll_add_target_result(&mut ui_state, &mut targets); + let after = render_fingerprint(&ui_state, targets.len(), Some(0), None); + assert_eq!(targets, vec![existing, added]); + assert_eq!(ui_state.selected_target, 1); + assert!(!ui_state.show_target_input); + assert!(ui_state.status_message.is_some()); + assert_ne!(before, after); + } + + #[test] + fn test_fingerprint_changes_on_update_check_recv() { + let mut ui_state = UiState::default(); + let (tx, rx) = std::sync::mpsc::channel(); + ui_state.update_rx = Some(rx); + let before = render_fingerprint(&ui_state, 1, Some(0), None); + tx.send(Some("0.22.0".to_string())).unwrap(); + poll_update_check(&mut ui_state); + let after = render_fingerprint(&ui_state, 1, Some(0), None); + assert_eq!(ui_state.update_available.as_deref(), Some("0.22.0")); + assert!(ui_state.update_rx.is_none()); + assert_ne!(before, after); + } + + #[test] + fn test_fingerprint_changes_when_open_target_list_refreshes() { + let current = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); + let other = IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)); + let current_session = + Session::new(Target::new("a".to_string(), current), Config::default()); + let other_session = Session::new(Target::new("b".to_string(), other), Config::default()); + let mut map = HashMap::new(); + map.insert(current, Arc::new(SessionLock::new(current_session))); + map.insert(other, Arc::new(SessionLock::new(other_session))); + let sessions: SessionMap = Arc::new(parking_lot::RwLock::new(map)); + let targets = vec![current, other]; + + let mut ui_state = UiState { + show_target_list: true, + target_list_tick: 29, + target_list_cache: Some(extract_target_infos(&sessions, &targets)), + ..UiState::default() + }; + let current_gen = sessions + .read() + .get(¤t) + .unwrap() + .read() + .render_generation; + let before = render_fingerprint(&ui_state, targets.len(), Some(current_gen), None); + + { + let sessions_read = sessions.read(); + let mut other_session = sessions_read.get(&other).unwrap().write(); + other_session.dest_ttl = Some(1); + if let Some(hop) = other_session.hop_mut(1) { + hop.sent = 10; + hop.received = 5; + } + } + + refresh_target_list_cache(&mut ui_state, &sessions, &targets); + let current_gen_after = sessions + .read() + .get(¤t) + .unwrap() + .read() + .render_generation; + let after = render_fingerprint(&ui_state, targets.len(), Some(current_gen_after), None); + + assert_eq!(current_gen, current_gen_after); + assert_eq!(ui_state.target_list_tick, 30); + assert_ne!(before, after); + } + + #[test] + fn test_fingerprint_includes_ix_fetched_at() { + let stale = crate::lookup::ix::CacheStatus { + loaded: true, + prefix_count: 3, + fetched_at: Some(1), + expired: false, + refreshing: false, + }; + let fresh = crate::lookup::ix::CacheStatus { + fetched_at: Some(2), + ..stale.clone() + }; + let ui_state = UiState::default(); + let a = render_fingerprint(&ui_state, 0, None, Some(&stale)); + let b = render_fingerprint(&ui_state, 0, None, Some(&fresh)); + assert_ne!(a, b); + } } ++++++ ttl.obsinfo ++++++ --- /var/tmp/diff_new_pack.c14MWX/_old 2026-09-04 12:39:16.927207539 +0200 +++ /var/tmp/diff_new_pack.c14MWX/_new 2026-09-04 12:39:16.944208136 +0200 @@ -1,5 +1,5 @@ name: ttl -version: 0.21.0 -mtime: 1783435342 -commit: 46f1dc2cd8c055408a764d5674afa897ca7cd30c +version: 0.22.0 +mtime: 1787589113 +commit: 987b926707b3ae64de54793c49c7bae104498402 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/ttl/vendor.tar.zst /work/SRC/openSUSE:Factory/.ttl.new.1265/vendor.tar.zst differ: char 7, line 1
