fix(git-status): throttle opportunistic probes per repo, not per cwd

The counts a probe produces are repo-wide — `git diff --numstat HEAD`
ignores the subdirectory it ran in — so panes at `repo/`, `repo/src` and
`repo/docs` were three ways of asking one question, and a window
activation spent one full-repo diff on each.

Count the throttle against the work-tree root once a probe has resolved
one for the cwd, and stamp the clock when the probe is *claimed* rather
than when it lands: without that, panes claiming in the same instant all
pass a throttle nothing has answered yet, which is exactly the shape a
window activation has. In-flight dedup stays keyed by cwd, since it
brackets a specific spawn that finish_probe has to release.
This commit is contained in:
l0ng-ai
2026-07-23 17:09:20 +08:00
parent 8955b1545f
commit 7ab19f14f6
3 changed files with 98 additions and 130 deletions
Generated
+16 -124
View File
@@ -2500,7 +2500,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree 0.20.0",
"roxmltree",
]
[[package]]
@@ -3151,7 +3151,7 @@ dependencies = [
"raw-window-handle",
"refineable",
"regex",
"resvg 0.45.1",
"resvg",
"scheduler",
"schemars",
"seahash",
@@ -3167,7 +3167,7 @@ dependencies = [
"thiserror 2.0.18",
"ttf-parser",
"url",
"usvg 0.45.1",
"usvg",
"util_macros",
"uuid",
"waker-fn",
@@ -3209,7 +3209,7 @@ dependencies = [
"paste",
"raw-window-handle",
"regex",
"resvg 0.45.1",
"resvg",
"ropey",
"rust-i18n",
"schemars",
@@ -4056,12 +4056,6 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "imgref"
version = "1.12.2"
@@ -4494,18 +4488,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
[[package]]
name = "kv-log-macro"
version = "1.0.7"
@@ -6307,15 +6289,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "polyval"
version = "0.7.2"
@@ -7084,26 +7057,12 @@ dependencies = [
"log",
"pico-args",
"rgb",
"svgtypes 0.15.3",
"tiny-skia 0.11.4",
"usvg 0.45.1",
"svgtypes",
"tiny-skia",
"usvg",
"zune-jpeg 0.4.21",
]
[[package]]
name = "resvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
dependencies = [
"log",
"pico-args",
"rgb",
"svgtypes 0.16.1",
"tiny-skia 0.12.0",
"usvg 0.47.0",
]
[[package]]
name = "rfc6979"
version = "0.6.0"
@@ -7158,15 +7117,6 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rsa"
version = "0.10.0-rc.18"
@@ -8512,17 +8462,7 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
dependencies = [
"kurbo 0.11.3",
"siphasher",
]
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo 0.13.1",
"kurbo",
"siphasher",
]
@@ -8859,22 +8799,7 @@ dependencies = [
"cfg-if",
"log",
"png 0.17.16",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png 0.18.1",
"tiny-skia-path 0.12.0",
"tiny-skia-path",
]
[[package]]
@@ -8888,17 +8813,6 @@ dependencies = [
"strict-num",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -9292,13 +9206,13 @@ dependencies = [
"portable-pty",
"regex",
"reqwest_client",
"resvg 0.47.0",
"resvg",
"russh",
"russh-sftp",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"sha2 0.10.9",
"smallvec",
"smol",
"tokio",
@@ -9465,45 +9379,23 @@ dependencies = [
"data-url",
"flate2",
"fontdb",
"imagesize 0.13.0",
"kurbo 0.11.3",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree 0.20.0",
"roxmltree",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes 0.15.3",
"tiny-skia-path 0.11.4",
"svgtypes",
"tiny-skia-path",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "usvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
"base64",
"data-url",
"flate2",
"imagesize 0.14.0",
"kurbo 0.13.1",
"log",
"pico-args",
"roxmltree 0.21.1",
"simplecss",
"siphasher",
"strict-num",
"svgtypes 0.16.1",
"tiny-skia-path 0.12.0",
"xmlwriter",
]
[[package]]
name = "utf-8"
version = "0.7.6"
+77 -4
View File
@@ -193,34 +193,70 @@ impl GitStatusCache {
/// call — rather than by a rare edge like a command ending.
///
/// Unlike [`begin_probe`](Self::begin_probe) this declines instead of
/// queueing: a probe already in flight, or one that landed less than
/// `min_interval` ago, drops the trigger entirely (no dirty mark, no
/// queueing: a probe already in flight, or one against a repo probed less
/// than `min_interval` ago, drops the trigger entirely (no dirty mark, no
/// rerun). That's the whole point of the two entry points — the rare edges
/// must never be missed, while these signals repeat on their own, so a
/// count that's a second stale beats a `git` storm across every pane of a
/// repo the moment the user alt-tabs back.
///
/// The throttle counts per *repo*, not per cwd (see
/// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock
/// rather than waiting for the landing: without that, a dozen panes
/// scattered over one repo's subdirectories would all claim in the same
/// instant — each of them passing a throttle no probe had answered yet —
/// and produce a dozen identical full-repo diffs.
pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool {
if self.in_flight.contains(cwd) {
return false;
}
let key = self.throttle_key(cwd).to_path_buf();
if self
.last_probe
.get(cwd)
.get(&key)
.is_some_and(|at| at.elapsed() < min_interval)
{
return false;
}
self.last_probe.insert(key, Instant::now());
self.in_flight.insert(cwd.to_path_buf());
true
}
/// What the opportunistic throttle counts against: the work-tree root once
/// some probe has answered for `cwd`, and `cwd` itself before that.
///
/// The counts a probe produces are repo-wide — `git diff --numstat HEAD`
/// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src`
/// and `repo/docs` are three ways of asking one question, and want one
/// shared clock rather than one each. In-flight dedup stays keyed by cwd:
/// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe)
/// has to be able to release exactly what was claimed.
///
/// Before any probe has landed the root is simply unknown, so the first
/// sweep over a repo still costs one probe per distinct cwd; every sweep
/// after that collapses to one.
fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path {
match self.roots.get(cwd) {
Some(Some(root)) => root,
_ => cwd,
}
}
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
/// live repo keeps the root's previous counts (a transient `git` error is
/// not "the tree went clean"). Returns whether the cwd was re-triggered
/// while this probe flew — the caller should start one more probe.
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
self.in_flight.remove(cwd);
self.last_probe.insert(cwd.to_path_buf(), Instant::now());
// Re-stamp on landing so the gap is measured from fresh counts, and
// under the root this probe just resolved — which is how a cwd first
// learns to share its repo's clock (at claim time it had none).
let key = match &snapshot {
Some(snap) => snap.root.clone(),
None => self.throttle_key(cwd).to_path_buf(),
};
self.last_probe.insert(key, Instant::now());
match snapshot {
Some(snap) => {
let (added, removed) = snap.counts.unwrap_or_else(|| {
@@ -516,4 +552,41 @@ mod tests {
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
assert!(cache.begin_probe(cwd));
}
/// The throttle is per repo, not per cwd: panes sitting in different
/// subdirectories ask one question (the counts are repo-wide), so once the
/// cache knows where they live, a window activation costs one probe for
/// the repo rather than one per pane.
#[test]
fn throttle_collapses_subdirectories_of_one_repo() {
let mut cache = GitStatusCache::default();
let (top, src, docs) = (
Path::new("/repo"),
Path::new("/repo/src"),
Path::new("/repo/docs"),
);
let gap = Duration::from_secs(60);
// Nothing known yet, so each cwd is its own key and each gets a probe.
for cwd in [top, src, docs] {
assert!(cache.begin_probe_throttled(cwd, gap));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1))))));
}
// Now all three resolve to `/repo`, so the next sweep collapses: the
// first pane to ask spends the probe and the rest ride on it.
assert!(!cache.begin_probe_throttled(top, gap));
assert!(!cache.begin_probe_throttled(src, gap));
// …and the claim itself is what stops the stampede — with the clock
// wound back far enough to let one through, the *others* still decline
// while it is in flight, even though nothing has landed yet.
assert!(cache.begin_probe_throttled(docs, Duration::ZERO));
assert!(!cache.begin_probe_throttled(top, gap));
assert!(!cache.begin_probe_throttled(src, gap));
// A pane elsewhere is untouched by any of it.
let other = Path::new("/other");
assert!(cache.begin_probe_throttled(other, gap));
}
}
+5 -2
View File
@@ -2083,8 +2083,11 @@ impl Tty7App {
/// right after switching back.
///
/// Panes sharing a cwd fold into one probe in the shared cache, and the
/// throttle there drops anything probed in the last moment, so the cost of
/// a window with many panes is bounded by the number of distinct repos.
/// throttle there counts per repo rather than per cwd, so once the cache
/// knows where each pane lives the cost of a window with many panes is
/// bounded by the number of distinct repos — not by the number of
/// subdirectories they happen to sit in, which would be the same full-repo
/// `git diff` asked several times over.
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) {
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));